diff --git a/assets/images/bcpl_logo.png b/assets/images/bcpl_logo.png index 50015bc..8dfeb19 100644 Binary files a/assets/images/bcpl_logo.png and b/assets/images/bcpl_logo.png differ diff --git a/lib/core/utils/active_option.dart b/lib/core/utils/active_option.dart new file mode 100644 index 0000000..608f686 --- /dev/null +++ b/lib/core/utils/active_option.dart @@ -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 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); +} diff --git a/lib/modules/assets/data/datasources/asset_remote_data_source.dart b/lib/modules/assets/data/datasources/asset_remote_data_source.dart index 50e83d0..add3d17 100644 --- a/lib/modules/assets/data/datasources/asset_remote_data_source.dart +++ b/lib/modules/assets/data/datasources/asset_remote_data_source.dart @@ -109,7 +109,9 @@ class AssetRemoteDataSource { ApiEndpoints.itemCategories, queryParameters: const {'limit': 100, 'is_active': true}, ); - return _parseList(response.data, AssetCategoryModel.fromJson); + return _parseList(response.data, AssetCategoryModel.fromJson) + .where((category) => category.isActive) + .toList(); } Future getAssetOptions() async { diff --git a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart index 100c0de..b415e26 100644 --- a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart +++ b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../core/network/api_handler.dart'; +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; @@ -116,22 +117,41 @@ Future> _safeOptions( Future> _safeVendorOptions(Ref ref) async { try { - final result = await ref.read(vendorRepositoryProvider).getVendors( - const VendorListQuery( - page: 1, - limit: AppConstants.maxPageSize, - status: 'active', - ), - ); - if (result.failure != null || result.data == null) return const []; - return result.data!.items - .map( - (vendor) => FilterOptionModel( - id: vendor.id, - name: vendor.vendorName, - ), - ) - .toList(); + final vendors = []; + var page = 1; + var totalPages = 1; + + while (page <= totalPages) { + final result = await ref.read(vendorRepositoryProvider).getVendors( + VendorListQuery( + page: page, + limit: AppConstants.maxPageSize, + isActive: true, + ), + ); + if (result.failure != null || result.data == null) return vendors; + + final data = result.data!; + vendors.addAll( + data.items + .where( + (vendor) => isActiveVendorOption( + isActive: vendor.isActive, + status: vendor.status, + ), + ) + .map( + (vendor) => FilterOptionModel( + id: vendor.id, + name: vendor.vendorName, + ), + ), + ); + totalPages = data.totalPages; + page++; + } + + return vendors; } catch (_) { return const []; } @@ -149,10 +169,12 @@ Future> _safeUserOptions(Ref ref) async { ); if (result.failure != null || result.data == null) return const []; return result.data!.items - .where((user) { - final status = user.status.trim().toLowerCase(); - return status == 'active' && user.isActive; - }) + .where( + (user) => isActiveUserOption( + status: user.status, + isActive: user.isActive, + ), + ) .map( (user) => FilterOptionModel( id: user.id, diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index 232e244..dddf8eb 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/can_permission.dart'; @@ -135,21 +136,15 @@ class _AssetListScreenState extends ConsumerState { children: [ Padding( padding: const EdgeInsets.all(16), - child: LayoutBuilder( - builder: (context, constraints) { - return _AssetsFilterBar( - wrapped: constraints.maxWidth < 1000, - query: state.query, - categories: allCategories, - plants: allPlants, - statuses: lookups?.statuses ?? const [], - onSearch: notifier.setSearch, - onCategoryChanged: - notifier.setCategoryFilter, - onPlantChanged: notifier.setPlantFilter, - onStatusChanged: notifier.setStatusFilter, - ); - }, + child: _AssetsFilterBar( + query: state.query, + categories: allCategories, + plants: allPlants, + statuses: lookups?.statuses ?? const [], + onSearch: notifier.setSearch, + onCategoryChanged: notifier.setCategoryFilter, + onPlantChanged: notifier.setPlantFilter, + onStatusChanged: notifier.setStatusFilter, ), ), const Divider(height: 1), @@ -275,7 +270,6 @@ class _AssetListScreenState extends ConsumerState { class _AssetsFilterBar extends StatelessWidget { const _AssetsFilterBar({ - required this.wrapped, required this.query, required this.categories, required this.plants, @@ -286,7 +280,6 @@ class _AssetsFilterBar extends StatelessWidget { required this.onStatusChanged, }); - final bool wrapped; final AssetListQuery query; final List categories; final List plants; @@ -315,7 +308,7 @@ class _AssetsFilterBar extends StatelessWidget { final categoryOptions = >[ const AppDropdownOption(value: null, label: 'All Categories'), for (final category in categories) - if (int.tryParse(category.id) != null) + if (category.isActive && int.tryParse(category.id) != null) AppDropdownOption( value: int.parse(category.id), label: category.name, @@ -371,29 +364,9 @@ class _AssetsFilterBar extends StatelessWidget { ), ]; - if (wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - ...filters.expand((f) => [f, const SizedBox(height: 12)]).toList() - ..removeLast(), - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(flex: 3, child: searchField), - const SizedBox(width: 12), - Expanded(flex: 2, child: filters[0]), - const SizedBox(width: 12), - Expanded(flex: 2, child: filters[1]), - const SizedBox(width: 12), - Expanded(child: filters[2]), - ], + return AppResponsiveFilterBar( + search: searchField, + filters: filters, ); } } diff --git a/lib/modules/assets/presentation/widgets/asset_form_panel.dart b/lib/modules/assets/presentation/widgets/asset_form_panel.dart index 330e1ed..02c4da9 100644 --- a/lib/modules/assets/presentation/widgets/asset_form_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_form_panel.dart @@ -12,6 +12,7 @@ import '../../../../shared/models/user_management_models.dart' show FilterOption import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; @@ -880,10 +881,9 @@ class _AssetFormPanelState extends ConsumerState { ), ), const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Active'), - subtitle: const Text('Inactive assets are hidden from active lists'), + AppFormToggleField( + label: 'Active', + subtitle: 'Inactive assets are hidden from active lists', value: _isActive, onChanged: (value) => setState(() => _isActive = value), ), @@ -1079,7 +1079,9 @@ class _AssetFormPanelState extends ConsumerState { } Widget _categoryDropdown(List categories) { - final categoryIds = categories + final activeCategories = + categories.where((category) => category.isActive).toList(); + final categoryIds = activeCategories .map((c) => int.tryParse(c.id)) .whereType() .toList(); @@ -1089,7 +1091,7 @@ class _AssetFormPanelState extends ConsumerState { value: _dropdownValue(_categoryId, categoryIds), searchHint: 'Search category...', isDense: true, - options: categories + options: activeCategories .map( (c) => AppDropdownOption( value: int.tryParse(c.id) ?? 0, @@ -1105,7 +1107,8 @@ class _AssetFormPanelState extends ConsumerState { onChanged: (v) => setState(() { _categoryId = v; _subcategoryId = null; - final selectedCategory = categories.where((c) => int.tryParse(c.id) == v).firstOrNull; + final selectedCategory = + activeCategories.where((c) => int.tryParse(c.id) == v).firstOrNull; if (selectedCategory != null) { if (selectedCategory.defaultDepreciationMethod != null && selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) { diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index bd97afd..2bac004 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -11,6 +11,7 @@ import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; +import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; @@ -360,9 +361,8 @@ class _AddAmcPanelState extends ConsumerState { maxLines: 3, ), const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Active'), + AppFormToggleField( + label: 'Active', value: _isActive, onChanged: (value) => setState(() => _isActive = value), ), @@ -804,9 +804,8 @@ class _LogServiceVisitPanelState extends ConsumerState { maxLines: 3, ), const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Under AMC'), + AppFormToggleField( + label: 'Under AMC', value: _isUnderAmc, onChanged: (value) => setState(() => _isUnderAmc = value), ), @@ -1151,21 +1150,18 @@ class _AddInsurancePanelState extends ConsumerState { ), ), const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Auto Renewal'), + AppFormToggleField( + label: 'Auto Renewal', value: _isAutoRenewal, onChanged: (value) => setState(() => _isAutoRenewal = value), ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Premium Paid'), + AppFormToggleField( + label: 'Premium Paid', value: _premiumPaid, onChanged: (value) => setState(() => _premiumPaid = value), ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Active'), + AppFormToggleField( + label: 'Active', value: _isActive, onChanged: (value) => setState(() => _isActive = value), ), diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index 358ebb0..02d98b4 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -16,6 +16,8 @@ import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_filter_date_field.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_responsive_filter_bar.dart'; +import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_status_chip.dart'; @@ -151,24 +153,19 @@ class _AuditLogsScreenState extends ConsumerState { ), Expanded( child: AppTableShell( - toolbar: LayoutBuilder( - builder: (context, constraints) { - return _FiltersBar( - searchController: _searchController, - filters: state.filters, - query: state.query, - wrapped: constraints.maxWidth < 1100, - onSearch: notifier.setSearch, - onTableChanged: notifier.setTableName, - onActionChanged: notifier.setAction, - onPerformerChanged: notifier.setPerformedBy, - onPickDateRange: () => _pickDateRange(state.query), - onClearDateRange: () => notifier.setDateRange(null, null), - onReset: () { - _searchController.clear(); - notifier.resetFilters(); - }, - ); + toolbar: _FiltersBar( + searchController: _searchController, + filters: state.filters, + query: state.query, + onSearch: notifier.setSearch, + onTableChanged: notifier.setTableName, + onActionChanged: notifier.setAction, + onPerformerChanged: notifier.setPerformedBy, + onPickDateRange: () => _pickDateRange(state.query), + onClearDateRange: () => notifier.setDateRange(null, null), + onReset: () { + _searchController.clear(); + notifier.resetFilters(); }, ), footer: AppPagination( @@ -237,7 +234,6 @@ class _FiltersBar extends StatelessWidget { required this.searchController, required this.filters, required this.query, - required this.wrapped, required this.onSearch, required this.onTableChanged, required this.onActionChanged, @@ -250,7 +246,6 @@ class _FiltersBar extends StatelessWidget { final TextEditingController searchController; final AuditLogFilterOptions filters; final AuditLogListQuery query; - final bool wrapped; final ValueChanged onSearch; final ValueChanged onTableChanged; final ValueChanged onActionChanged; @@ -270,24 +265,11 @@ class _FiltersBar extends StatelessWidget { @override Widget build(BuildContext context) { - final searchField = TextField( + final searchField = AppSearchField( controller: searchController, + hint: 'Search table, action, request ID...', onChanged: onSearch, - decoration: InputDecoration( - labelText: 'Search', - hintText: 'Search table, action, request ID...', - prefixIcon: const Icon(Icons.search), - isDense: true, - suffixIcon: searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - searchController.clear(); - onSearch(''); - }, - ) - : null, - ), + onClear: () => onSearch(''), ); final tableDropdown = AppSearchableDropdown( @@ -350,51 +332,17 @@ class _FiltersBar extends StatelessWidget { child: const Text('Reset'), ); - if (wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - tableDropdown, - const SizedBox(height: 12), - actionDropdown, - const SizedBox(height: 12), - performerDropdown, - const SizedBox(height: 12), - Row( - children: [ - Expanded(child: dateField), - resetButton, - ], - ), - ], - ); - } - - Widget row(List cells) { - assert(cells.length == 4); - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - for (var i = 0; i < cells.length; i++) ...[ - if (i > 0) const SizedBox(width: 12), - Expanded(child: cells[i]), - ], - ], - ); - } - - return Column( - children: [ - row([searchField, tableDropdown, actionDropdown, performerDropdown]), - const SizedBox(height: 12), - row([ - dateField, - const SizedBox.shrink(), - const SizedBox.shrink(), - Align(alignment: Alignment.centerRight, child: resetButton), - ]), + return AppResponsiveFilterGrid( + fields: [ + searchField, + tableDropdown, + actionDropdown, + performerDropdown, + dateField, + Align( + alignment: Alignment.centerRight, + child: resetButton, + ), ], ); } diff --git a/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart b/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart index 55870e3..9f4a60f 100644 --- a/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart +++ b/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart @@ -44,6 +44,9 @@ class _DetailBody extends StatelessWidget { @override Widget build(BuildContext context) { + final hasOld = detail.oldValue != null || detail.hasOldValue; + final hasNew = detail.newValue != null || detail.hasNewValue; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -69,20 +72,7 @@ class _DetailBody extends StatelessWidget { _DetailRow(label: 'Request ID', value: detail.requestId ?? '—'), ], ), - if (detail.oldValue != null || detail.hasOldValue) - _ReadableValueSection( - title: 'OLD VALUE', - value: detail.oldValue, - emptyLabel: 'No previous value recorded', - ), - if (detail.newValue != null || detail.hasNewValue) - _ReadableValueSection( - title: 'NEW VALUE', - value: detail.newValue, - emptyLabel: 'No new value recorded', - ), - if ((detail.oldValue != null || detail.hasOldValue) && - (detail.newValue != null || detail.hasNewValue)) + if (hasOld && hasNew) _ChangedFieldsSection( oldValue: detail.oldValue, newValue: detail.newValue, @@ -135,56 +125,6 @@ class _DetailRow extends StatelessWidget { } } -class _ReadableValueSection extends StatelessWidget { - const _ReadableValueSection({ - required this.title, - required this.value, - required this.emptyLabel, - }); - - final String title; - final Map? value; - final String emptyLabel; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final rows = value == null ? const <_FieldRow>[] : _flattenFields(value!); - - return SidePanelSection( - title: title, - children: [ - if (rows.isEmpty) - Text( - emptyLabel, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ) - else - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(14, 14, 14, 2), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest - .withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.12), - ), - ), - child: Column( - children: [ - for (final row in rows) - _DetailRow(label: row.label, value: row.value), - ], - ), - ), - ], - ); - } -} - class _ChangedFieldsSection extends StatelessWidget { const _ChangedFieldsSection({ required this.oldValue, @@ -238,7 +178,10 @@ class _ChangedFieldsSection extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 18), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 18, + ), child: Icon( Icons.arrow_forward, size: 16, @@ -385,7 +328,10 @@ String? _nestedSummary(Map nested) { if (nested.length <= 2) { return nested.entries - .map((e) => '${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}') + .map( + (e) => + '${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}', + ) .join(', '); } diff --git a/lib/modules/grn/presentation/providers/grn_lookups_provider.dart b/lib/modules/grn/presentation/providers/grn_lookups_provider.dart index 45dfe9f..1800b82 100644 --- a/lib/modules/grn/presentation/providers/grn_lookups_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_lookups_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; @@ -69,10 +70,12 @@ Future> _safeUserOptions(Ref ref) async { ); if (result.failure != null || result.data == null) return const []; return result.data!.items - .where((user) { - final status = user.status.trim().toLowerCase(); - return status == 'active' && user.isActive; - }) + .where( + (user) => isActiveUserOption( + status: user.status, + isActive: user.isActive, + ), + ) .map( (user) => FilterOptionModel( id: user.id, diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index 96f67a4..d38ec6d 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -318,7 +318,7 @@ class _GrnFormScreenState extends ConsumerState { : const AsyncData(null); return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, + backgroundColor: Theme.of(context).colorScheme.surface, body: lookupsAsync.when( skipLoadingOnReload: true, loading: () => lookupsAsync.hasValue @@ -388,236 +388,230 @@ class _GrnFormScreenState extends ConsumerState { return SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), - child: Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1200), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(existing), - const SizedBox(height: 16), - _SectionCard( - title: 'RECEIPT DETAILS', - child: Column( - children: [ - FormRowFour( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(existing), + const SizedBox(height: 16), + _SectionCard( + title: 'RECEIPT DETAILS', + child: Column( children: [ - _DateField( - label: 'GRN Date *', - value: _grnDate, - enabled: !widget.isEditing, - onTap: widget.isEditing - ? null - : () => _pickDate( - current: _grnDate, - onPicked: (d) => - setState(() => _grnDate = d), - ), - ), - if (!widget.isEditing) - AppSearchableDropdown( - label: 'Purchase Order *', - value: _selectedPoId, - hint: 'Select PO', - searchHint: 'Search PO...', - options: poOptions, - onChanged: (v) async { - setState(() => _selectedPoId = v); - if (v == null) { - for (final line in _lines) { - line.dispose(); - } - setState(() => _lines.clear()); - return; - } - try { - final po = await ref.read( - grnPurchaseOrderProvider(v).future, - ); - if (mounted && po != null) { - _loadLinesFromPo(po); - } - } catch (e) { - if (!mounted) return; - showAppToastFromSnackBar(context, - SnackBar(content: Text(e.toString())), - ); - } - }, - validator: (v) => v == null - ? 'Purchase Order Is Required' - : null, - ) - else - _ReadOnlyField( - label: 'Purchase Order', - value: existing?.poNumber ?? '—', - ), - MasterQuickAddDropdown( - masterId: 'warehouses', - label: 'Warehouse *', - value: _dropdownValue(_warehouseId, warehouseIds), - hint: 'Select warehouse', - searchHint: 'Search warehouse...', - options: _intOptions(lookups.warehouses), - refreshLookups: () => - ref.invalidate(grnLookupsProvider), - parseCreatedId: int.tryParse, - onChanged: widget.isEditing - ? (_) {} - : (v) => setState(() => _warehouseId = v), - validator: widget.isEditing - ? null - : (v) => - v == null ? 'Warehouse is required' : null, - enabled: !widget.isEditing, - ), - AppTextField( - label: 'Vendor Invoice No', - controller: _vendorInvoiceNoController, - ), - ], - ), - FormRowFour( - children: [ - _DateField( - label: 'Vendor Invoice Date', - value: _vendorInvoiceDate, - onTap: () => _pickDate( - current: _vendorInvoiceDate, - onPicked: (d) => - setState(() => _vendorInvoiceDate = d), - ), - ), - AppTextField( - label: 'Vendor Invoice Amount', - controller: _vendorInvoiceAmountController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d*\.?\d{0,2}'), + FormRowFour( + children: [ + _DateField( + label: 'GRN Date *', + value: _grnDate, + enabled: !widget.isEditing, + onTap: widget.isEditing + ? null + : () => _pickDate( + current: _grnDate, + onPicked: (d) => + setState(() => _grnDate = d), + ), + ), + if (!widget.isEditing) + AppSearchableDropdown( + label: 'Purchase Order *', + value: _selectedPoId, + hint: 'Select PO', + searchHint: 'Search PO...', + options: poOptions, + onChanged: (v) async { + setState(() => _selectedPoId = v); + if (v == null) { + for (final line in _lines) { + line.dispose(); + } + setState(() => _lines.clear()); + return; + } + try { + final po = await ref.read( + grnPurchaseOrderProvider(v).future, + ); + if (mounted && po != null) { + _loadLinesFromPo(po); + } + } catch (e) { + if (!mounted) return; + showAppToastFromSnackBar(context, + SnackBar(content: Text(e.toString())), + ); + } + }, + validator: (v) => v == null + ? 'Purchase Order Is Required' + : null, + ) + else + _ReadOnlyField( + label: 'Purchase Order', + value: existing?.poNumber ?? '—', + ), + MasterQuickAddDropdown( + masterId: 'warehouses', + label: 'Warehouse *', + value: _dropdownValue(_warehouseId, warehouseIds), + hint: 'Select warehouse', + searchHint: 'Search warehouse...', + options: _intOptions(lookups.warehouses), + refreshLookups: () => + ref.invalidate(grnLookupsProvider), + parseCreatedId: int.tryParse, + onChanged: widget.isEditing + ? (_) {} + : (v) => setState(() => _warehouseId = v), + validator: widget.isEditing + ? null + : (v) => + v == null ? 'Warehouse is required' : null, + enabled: !widget.isEditing, + ), + AppTextField( + label: 'Vendor Invoice No', + controller: _vendorInvoiceNoController, ), ], - validator: (v) => Validators.optionalNonNegativeDouble( - v, - fieldName: 'Vendor Invoice Amount', - ), ), - AppTextField( - label: 'Vehicle No', - controller: _vehicleNoController, + FormRowFour( + children: [ + _DateField( + label: 'Vendor Invoice Date', + value: _vendorInvoiceDate, + onTap: () => _pickDate( + current: _vendorInvoiceDate, + onPicked: (d) => + setState(() => _vendorInvoiceDate = d), + ), + ), + AppTextField( + label: 'Vendor Invoice Amount', + controller: _vendorInvoiceAmountController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d{0,2}'), + ), + ], + validator: (v) => Validators.optionalNonNegativeDouble( + v, + fieldName: 'Vendor Invoice Amount', + ), + ), + AppTextField( + label: 'Vehicle No', + controller: _vehicleNoController, + ), + AppTextField( + label: 'LR No', + controller: _lrNoController, + ), + ], ), - AppTextField( - label: 'LR No', - controller: _lrNoController, + FormRowFour( + children: [ + _DateField( + label: 'LR Date', + value: _lrDate, + onTap: () => _pickDate( + current: _lrDate, + onPicked: (d) => setState(() => _lrDate = d), + ), + ), + AppSearchableDropdown( + label: 'Received By', + value: _dropdownValue( + _normalizeUserId(_receivedById), + userIds, + ), + hint: 'Select user', + searchHint: 'Search user...', + options: _intOptions(lookups.users), + onChanged: (v) => + setState(() => _receivedById = v), + ), + AppSearchableDropdown( + label: 'Quality Checked By', + value: _dropdownValue( + _normalizeUserId(_qualityCheckedById), + userIds, + ), + hint: 'Select user', + searchHint: 'Search user...', + options: _intOptions(lookups.users), + onChanged: (v) => + setState(() => _qualityCheckedById = v), + ), + const SizedBox.shrink(), + ], ), ], ), - FormRowFour( - children: [ - _DateField( - label: 'LR Date', - value: _lrDate, - onTap: () => _pickDate( - current: _lrDate, - onPicked: (d) => setState(() => _lrDate = d), + ), + const SizedBox(height: 16), + if (!widget.isEditing) + GrnLineItemsEditor( + items: _lines, + onChanged: () => setState(() {}), + ) + else ...[ + _SectionCard( + title: 'LINE ITEMS · ${existing?.items.length ?? 0}', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Line items cannot be changed after posting.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - AppSearchableDropdown( - label: 'Received By', - value: _dropdownValue( - _normalizeUserId(_receivedById), - userIds, - ), - hint: 'Select user', - searchHint: 'Search user...', - options: _intOptions(lookups.users), - onChanged: (v) => - setState(() => _receivedById = v), - ), - AppSearchableDropdown( - label: 'Quality Checked By', - value: _dropdownValue( - _normalizeUserId(_qualityCheckedById), - userIds, - ), - hint: 'Select user', - searchHint: 'Search user...', - options: _intOptions(lookups.users), - onChanged: (v) => - setState(() => _qualityCheckedById = v), - ), - const SizedBox.shrink(), - ], + if (existing?.items.isNotEmpty == true) ...[ + const SizedBox(height: 12), + GrnItemsTable(items: existing!.items), + ], + ], + ), ), ], - ), - ), - const SizedBox(height: 16), - if (!widget.isEditing) - GrnLineItemsEditor( - items: _lines, - onChanged: () => setState(() {}), - ) - else ...[ - _SectionCard( - title: 'LINE ITEMS · ${existing?.items.length ?? 0}', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + const SizedBox(height: 16), + _SectionCard( + title: 'ADDITIONAL DETAILS', + child: AppTextField( + controller: _remarksController, + label: 'Remarks', + hint: 'Any additional notes for this receipt.', + maxLines: 4, + ), + ), + const SizedBox(height: 20), + Row( children: [ Text( - 'Line items cannot be changed after posting.', - style: theme.textTheme.bodyMedium?.copyWith( + 'Fields marked * are required', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + Text( + widget.isEditing + ? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only' + : '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved', + style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), - if (existing?.items.isNotEmpty == true) ...[ - const SizedBox(height: 12), - GrnItemsTable(items: existing!.items), - ], ], ), - ), - ], - const SizedBox(height: 16), - _SectionCard( - title: 'ADDITIONAL DETAILS', - child: AppTextField( - controller: _remarksController, - label: 'Remarks', - hint: 'Any additional notes for this receipt.', - maxLines: 4, - ), - ), - const SizedBox(height: 20), - Row( - children: [ - Text( - 'Fields marked * are required', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const Spacer(), - Text( - widget.isEditing - ? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only' - : '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), ], ), - ], - ), - ), - ), ), ); } diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index 3258e82..e56b407 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -15,6 +15,7 @@ import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; @@ -83,20 +84,15 @@ class _GrnListScreenState extends ConsumerState { ), Expanded( child: AppTableShell( - toolbar: LayoutBuilder( - builder: (context, constraints) { - return _FiltersBar( - searchController: _searchController, - query: state.query, - wrapped: constraints.maxWidth < 900, - showExport: canExport, - isExporting: state.isExporting, - onExport: _exportGrns, - onSearch: ref.read(grnListProvider.notifier).setSearch, - onStatusChanged: - ref.read(grnListProvider.notifier).setStatusFilter, - ); - }, + toolbar: _FiltersBar( + searchController: _searchController, + query: state.query, + showExport: canExport, + isExporting: state.isExporting, + onExport: _exportGrns, + onSearch: ref.read(grnListProvider.notifier).setSearch, + onStatusChanged: + ref.read(grnListProvider.notifier).setStatusFilter, ), footer: AppPagination( currentPage: state.query.page, @@ -191,7 +187,6 @@ class _FiltersBar extends StatelessWidget { const _FiltersBar({ required this.searchController, required this.query, - required this.wrapped, required this.onSearch, required this.onStatusChanged, this.showExport = false, @@ -201,7 +196,6 @@ class _FiltersBar extends StatelessWidget { final TextEditingController searchController; final GrnListQuery query; - final bool wrapped; final ValueChanged onSearch; final ValueChanged onStatusChanged; final bool showExport; @@ -212,25 +206,21 @@ class _FiltersBar extends StatelessWidget { Widget build(BuildContext context) { final searchField = Padding( padding: const EdgeInsets.only(top: 8), - child: SizedBox( - width: wrapped ? double.infinity : null, - child: TextField( - controller: searchController, - onChanged: onSearch, - decoration: const InputDecoration( - labelText: 'Search', - hintText: 'Search GRN number, PO, vendor...', - prefixIcon: Icon(Icons.search, size: 20), - floatingLabelBehavior: FloatingLabelBehavior.always, - isDense: true, - ), + child: TextField( + controller: searchController, + onChanged: onSearch, + decoration: const InputDecoration( + labelText: 'Search', + hintText: 'Search GRN number, PO, vendor...', + prefixIcon: Icon(Icons.search, size: 20), + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: true, ), ), ); - final statusFilter = SizedBox( - width: wrapped ? double.infinity : 180, - child: AppSearchableDropdown( + final filters = [ + AppSearchableDropdown( label: 'Status', value: query.status, searchHint: 'Search status...', @@ -243,46 +233,24 @@ class _FiltersBar extends StatelessWidget { ], onChanged: onStatusChanged, ), - ); + ]; - final exportButton = OutlinedButton.icon( - onPressed: isExporting ? null : onExport, - icon: isExporting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), + return AppResponsiveFilterBar( + search: searchField, + filters: filters, + trailing: showExport + ? OutlinedButton.icon( + onPressed: isExporting ? null : onExport, + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(isExporting ? 'Exporting...' : 'Export'), - ); - - if (wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - statusFilter, - if (showExport) ...[ - const SizedBox(height: 12), - Align(alignment: Alignment.centerRight, child: exportButton), - ], - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(flex: 3, child: searchField), - const SizedBox(width: 12), - Expanded(flex: 2, child: statusFilter), - if (showExport) ...[ - const SizedBox(width: 12), - exportButton, - ], - ], + : null, ); } } diff --git a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart index f0e4c94..b9e0688 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -664,11 +664,6 @@ class _GrnItemsHeader extends StatelessWidget { child: Text('CURRENT', style: style, textAlign: TextAlign.right), ), const SizedBox(width: 12), - SizedBox( - width: 80, - child: Text('RATE', style: style, textAlign: TextAlign.right), - ), - const SizedBox(width: 12), SizedBox(width: 90, child: Text('BATCH', style: style)), const SizedBox(width: 12), SizedBox(width: 90, child: Text('MFG', style: style)), @@ -714,11 +709,38 @@ class _GrnItemRow extends StatelessWidget { ), Expanded( flex: 3, - child: Text( - item.itemName ?? item.itemCode ?? '—', - style: strong, - maxLines: 2, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.itemName ?? item.itemCode ?? '—', + style: strong, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (item.remarks?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text( + item.remarks!.trim(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (item.rejectionReason?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 2), + Text( + 'Reject: ${item.rejectionReason!.trim()}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], ), ), const SizedBox(width: 12), @@ -749,15 +771,6 @@ class _GrnItemRow extends StatelessWidget { ), ), const SizedBox(width: 12), - SizedBox( - width: 80, - child: Text( - item.rate != null ? _formatQty(item.rate!) : '—', - style: body, - textAlign: TextAlign.right, - ), - ), - const SizedBox(width: 12), SizedBox( width: 90, child: Text( diff --git a/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart b/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart index 3502954..8183574 100644 --- a/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart +++ b/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../core/network/dio_client.dart'; +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../domain/entities/master_definition.dart'; @@ -41,6 +42,7 @@ class MasterCrudRemoteDataSource { int page = 1, int limit = 20, String? search, + bool? isActive, }) async { final response = await dio.get( definition.apiPath, @@ -48,6 +50,7 @@ class MasterCrudRemoteDataSource { 'page': page, 'limit': limit, if (search != null && search.isNotEmpty) 'search': search, + if (isActive != null) 'is_active': isActive, }, ); @@ -85,10 +88,9 @@ class MasterCrudRemoteDataSource { definition, page: page, limit: AppConstants.defaultPageSize, + isActive: true, ); - allItems.addAll( - result.items.where((item) => item['is_active'] != false), - ); + allItems.addAll(result.items.where(isActiveOptionRow)); if (page >= result.totalPages) break; page++; } diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart index 7831ee8..a3611e4 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -189,18 +189,13 @@ class _MasterListScreenState extends ConsumerState { ), Expanded( child: AppTableShell( - toolbar: LayoutBuilder( - builder: (context, constraints) { - return AppSearchExportBar( - wrapped: constraints.maxWidth < 640, - searchController: _searchController, - searchHint: _searchHint(def), - isExporting: state.isExporting, - showExport: canExport, - onSearch: notifier.setSearch, - onExport: _exportRecords, - ); - }, + toolbar: AppSearchExportBar( + searchController: _searchController, + searchHint: _searchHint(def), + isExporting: state.isExporting, + showExport: canExport, + onSearch: notifier.setSearch, + onExport: _exportRecords, ), footer: AppPagination( currentPage: state.page, diff --git a/lib/modules/master_data/presentation/widgets/master_form_panel.dart b/lib/modules/master_data/presentation/widgets/master_form_panel.dart index 0c467de..0f39ab6 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -5,6 +5,7 @@ import '../../../../core/errors/failure.dart'; import '../../../../core/utils/validators.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; @@ -112,9 +113,8 @@ class _MasterFormPanelState extends ConsumerState { switch (field.type) { case MasterFieldType.boolean: if (field.key == 'is_active') { - return SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(field.label), + return AppFormToggleField( + label: field.label, value: value == true, onChanged: (checked) => notifier.updateValue(field.key, checked), ); diff --git a/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart b/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart index 5002a7d..1d97e97 100644 --- a/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart +++ b/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart @@ -5,6 +5,7 @@ import '../../../../core/errors/failure.dart'; import '../../../../core/utils/validators.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/error_view.dart'; @@ -103,10 +104,8 @@ class _MasterInlineCreateFormState switch (field.type) { case MasterFieldType.boolean: if (field.key == 'is_active') { - return SwitchListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text(field.label), + return AppFormToggleField( + label: field.label, value: value == true, onChanged: (checked) => notifier.updateValue(field.key, checked), ); @@ -363,20 +362,38 @@ class _MasterInlineCreateFormState @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final quickAddBackground = isDark + ? theme.colorScheme.primary.withValues(alpha: 0.16) + : theme.colorScheme.primary.withValues(alpha: 0.08); + final quickAddBorder = isDark + ? theme.colorScheme.primary.withValues(alpha: 0.65) + : theme.colorScheme.primary.withValues(alpha: 0.35); + final quickAddGlow = isDark + ? theme.colorScheme.primary.withValues(alpha: 0.22) + : theme.colorScheme.primary.withValues(alpha: 0.12); final formAsync = ref.watch(masterFormProvider(_args)); final def = _definition; final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false; return Material( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + color: quickAddBackground, borderRadius: BorderRadius.circular(12), clipBehavior: Clip.none, child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.28), + color: quickAddBorder, + width: 1.2, ), + boxShadow: [ + BoxShadow( + color: quickAddGlow, + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], ), child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), @@ -447,29 +464,40 @@ class _MasterInlineCreateFormState formState: formState, twoColumns: useTwoColumns, ), - if (active != null) ...[ - const SizedBox(height: 4), - _buildField( - field: active, - formState: formState, - ), - ], const SizedBox(height: 8), Row( - mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - TextButton( - onPressed: - isSubmitting ? null : widget.onCancel, - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - AppButton( - label: 'Add ${def.title}', - expand: false, - icon: Icons.check, - isLoading: isSubmitting, - onPressed: isSubmitting ? null : _submit, + if (active != null) + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: _buildField( + field: active, + formState: formState, + ), + ), + ) + else + const Spacer(), + const SizedBox(width: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: + isSubmitting ? null : widget.onCancel, + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + AppButton( + label: 'Add ${def.title}', + expand: false, + icon: Icons.check, + isLoading: isSubmitting, + onPressed: isSubmitting ? null : _submit, + ), + ], ), ], ), diff --git a/lib/modules/master_data/presentation/widgets/master_quick_add.dart b/lib/modules/master_data/presentation/widgets/master_quick_add.dart index ed6d5e0..f4d203c 100644 --- a/lib/modules/master_data/presentation/widgets/master_quick_add.dart +++ b/lib/modules/master_data/presentation/widgets/master_quick_add.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -47,7 +49,9 @@ class MasterQuickAddDropdown extends ConsumerStatefulWidget { final bool enabled; final bool isDense; final Map? 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 Function()? refreshLookups; final String? addNewLabel; @override @@ -114,8 +118,14 @@ class _MasterQuickAddDropdownState }); } - void _onSaved(String createdId) { - widget.refreshLookups?.call(); + Future _onSaved(String createdId) async { + try { + final refresh = widget.refreshLookups; + if (refresh != null) await refresh(); + } catch (_) { + // Still select the created row even if lookup refresh fails. + } + if (!mounted) return; if (createdId != 'created') { final parsed = widget.parseCreatedId(createdId); @@ -127,7 +137,8 @@ class _MasterQuickAddDropdownState _collapse(); if (!mounted) return; - showAppToastFromSnackBar(context, + showAppToastFromSnackBar( + context, SnackBar( content: Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'), diff --git a/lib/modules/masters/data/datasources/master_remote_data_source.dart b/lib/modules/masters/data/datasources/master_remote_data_source.dart index 5bccac6..f22290d 100644 --- a/lib/modules/masters/data/datasources/master_remote_data_source.dart +++ b/lib/modules/masters/data/datasources/master_remote_data_source.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../core/network/dio_client.dart'; +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/user_management_models.dart'; final masterRemoteDataSourceProvider = Provider((ref) { @@ -65,7 +66,7 @@ class MasterRemoteDataSource { final options = []; for (final item in rows) { - if (item['is_active'] == false) continue; + if (!isActiveOptionRow(item)) continue; final id = item['id']?.toString() ?? ''; if (id.isEmpty) continue; @@ -104,7 +105,7 @@ class MasterRemoteDataSource { final pctById = {}; for (final item in rows) { - if (item['is_active'] == false) continue; + if (!isActiveOptionRow(item)) continue; final id = item['id']?.toString() ?? ''; if (id.isEmpty) continue; @@ -135,7 +136,7 @@ class MasterRemoteDataSource { ); final categoryFilter = itemCategoryId?.toString(); return rows - .where((item) => item['is_active'] != false) + .where(isActiveOptionRow) .where( (item) => categoryFilter == null || @@ -157,7 +158,7 @@ class MasterRemoteDataSource { queryParameters: {'is_active': true}, ); return rows - .where((item) => item['is_active'] != false) + .where(isActiveOptionRow) .map( (item) => FilterOptionModel( id: item['id']?.toString() ?? '', diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart index 2bd8f55..ce133ec 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/vendor_model.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; @@ -157,11 +158,12 @@ Future> _fetchActiveVendors( final data = result.data!; vendors.addAll( data.items - .where((vendor) { - if (!vendor.isActive) return false; - final status = (vendor.status ?? '').trim().toLowerCase(); - return status != 'blacklisted'; - }) + .where( + (vendor) => isActiveVendorOption( + isActive: vendor.isActive, + status: vendor.status, + ), + ) .map( (vendor) => FilterOptionModel(id: vendor.id, name: vendor.vendorName), diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart index 2158d35..a842b60 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart @@ -366,243 +366,238 @@ class _PurchaseOrderFormScreenState extends ConsumerState _pickDate( - current: _poDate, - onPicked: (d) => setState(() => _poDate = d), - ), - ), - AppSearchableDropdown( - 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( - 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( - 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( - 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( - 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( - 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( - masterId: 'delivery_terms', - label: 'Delivery Term', - value: _deliveryTermId, - hint: 'Select delivery term', - searchHint: 'Search delivery term...', - options: - _nullableIntOptions(lookups.deliveryTerms), - refreshLookups: () => - ref.invalidate(purchaseOrderLookupsProvider), - parseCreatedId: int.tryParse, - onChanged: (v) => - setState(() => _deliveryTermId = v), - ), - ], - ), - FormRow( - columnCount: 4, - children: [ - _DateField( - label: 'Expected Delivery', - value: _expectedDeliveryDate, - onTap: () => _pickDate( - current: _expectedDeliveryDate, - onPicked: (d) => setState( - () => _expectedDeliveryDate = d, - ), - ), - ), - ], - ), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(existing), + if (_showReapprovalWarning(existing)) ...[ + const SizedBox(height: 8), + _ReapprovalBanner(), ], - ), - ), - const SizedBox(height: 16), - PurchaseOrderLineItemsEditor( - lines: _lines, - items: lookups.items, - itemHsnById: lookups.itemHsnById, - itemUomById: lookups.itemUomById, - itemGstRateById: lookups.itemGstRateById, - uom: lookups.uom, - gstRates: lookups.gstRates, - gstRatePctById: lookups.gstRatePctById, - onAddLine: _addLine, - onRemoveLine: _removeLine, - onChanged: () => setState(() {}), - ), - const SizedBox(height: 16), - LayoutBuilder( - builder: (context, constraints) { - final stack = constraints.maxWidth < 900; - final additional = _SectionCard( - title: 'ADDITIONAL DETAILS', + const SizedBox(height: 16), + _SectionCard( + title: 'ORDER DETAILS', child: Column( children: [ - AppTextField( - controller: _termsController, - label: 'Terms & Conditions', - hint: 'Payment terms, inspection conditions, etc.', - maxLines: 5, + FormRowFour( + children: [ + _DateField( + label: 'PO Date *', + value: _poDate, + onTap: () => _pickDate( + current: _poDate, + onPicked: (d) => setState(() => _poDate = d), + ), + ), + AppSearchableDropdown( + 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( + 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( + masterId: 'plants', + label: 'Plant *', + value: _dropdownValue(_plantId, plantIds), + hint: 'Select plant', + searchHint: 'Search plant...', + options: _intOptions(lookups.plants), + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, + onChanged: (v) => setState(() => _plantId = v), + validator: (v) => + v == null ? 'Plant is required' : null, + ), + ], ), - AppTextField( - controller: _remarksController, - label: 'Remarks', - hint: 'Any additional notes for this order.', - maxLines: 4, + FormRowFour( + children: [ + MasterQuickAddDropdown( + 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( + 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( + 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( + masterId: 'delivery_terms', + label: 'Delivery Term', + value: _deliveryTermId, + hint: 'Select delivery term', + searchHint: 'Search delivery term...', + options: + _nullableIntOptions(lookups.deliveryTerms), + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, + onChanged: (v) => + setState(() => _deliveryTermId = v), + ), + ], + ), + FormRow( + columnCount: 4, + children: [ + _DateField( + label: 'Expected Delivery', + value: _expectedDeliveryDate, + onTap: () => _pickDate( + current: _expectedDeliveryDate, + onPicked: (d) => setState( + () => _expectedDeliveryDate = d, + ), + ), + ), + ], ), ], ), - ); - final summary = _AmountSummaryCard( - totals: totals, - freightController: _freightController, - otherChargesController: _otherChargesController, - discountController: _discountController, - isEditing: widget.isEditing, - ); - - if (stack) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - additional, - const SizedBox(height: 16), - summary, - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 3, child: additional), - const SizedBox(width: 16), - Expanded(flex: 2, child: summary), - ], - ); - }, - ), - const SizedBox(height: 20), - Row( - children: [ - Text( - 'Fields marked * are required', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), ), - const Spacer(), - Text( - widget.isEditing - ? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft' - : '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + const SizedBox(height: 16), + PurchaseOrderLineItemsEditor( + lines: _lines, + items: lookups.items, + itemHsnById: lookups.itemHsnById, + itemUomById: lookups.itemUomById, + itemGstRateById: lookups.itemGstRateById, + uom: lookups.uom, + gstRates: lookups.gstRates, + gstRatePctById: lookups.gstRatePctById, + onAddLine: _addLine, + onRemoveLine: _removeLine, + onChanged: () => setState(() {}), + ), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + final stack = constraints.maxWidth < 900; + final additional = _SectionCard( + title: 'ADDITIONAL DETAILS', + child: Column( + children: [ + AppTextField( + controller: _termsController, + label: 'Terms & Conditions', + hint: + 'Payment terms, inspection conditions, etc.', + maxLines: 5, + ), + AppTextField( + controller: _remarksController, + label: 'Remarks', + hint: 'Any additional notes for this order.', + maxLines: 4, + ), + ], + ), + ); + final summary = _AmountSummaryCard( + totals: totals, + freightController: _freightController, + otherChargesController: _otherChargesController, + discountController: _discountController, + isEditing: widget.isEditing, + ); + + if (stack) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + additional, + const SizedBox(height: 16), + summary, + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: additional), + const SizedBox(width: 16), + Expanded(flex: 2, child: summary), + ], + ); + }, + ), + const SizedBox(height: 20), + Row( + children: [ + Text( + 'Fields marked * are required', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + Text( + widget.isEditing + ? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft' + : '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), ], ), - ], - ), - ), - ), ), ); } @@ -718,7 +713,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState const AppLoadingView(message: 'Loading form options...'), error: (e, _) => ErrorView.fromFailure( diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 5121981..c54157c 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -16,6 +16,7 @@ import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/can_permission.dart'; @@ -103,23 +104,18 @@ class _PurchaseOrderListScreenState extends ConsumerState> statusOptions; - final bool wrapped; final ValueChanged onSearch; final ValueChanged onStatusChanged; final ValueChanged onPoTypeChanged; @@ -327,48 +321,22 @@ class _FiltersBar extends StatelessWidget { ), ]; - final exportButton = OutlinedButton.icon( - onPressed: isExporting ? null : onExport, - icon: isExporting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), + return AppResponsiveFilterBar( + search: searchField, + filters: filters, + trailing: showExport + ? OutlinedButton.icon( + onPressed: isExporting ? null : onExport, + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(isExporting ? 'Exporting...' : 'Export'), - ); - - if (wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - filters[0], - const SizedBox(height: 12), - filters[1], - if (showExport) ...[ - const SizedBox(height: 12), - Align(alignment: Alignment.centerRight, child: exportButton), - ], - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(flex: 3, child: searchField), - const SizedBox(width: 12), - Expanded(flex: 2, child: filters[0]), - const SizedBox(width: 12), - Expanded(flex: 2, child: filters[1]), - if (showExport) ...[ - const SizedBox(width: 12), - exportButton, - ], - ], + : null, ); } } diff --git a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart index 6d11d44..4aa00e2 100644 --- a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart +++ b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart @@ -381,26 +381,78 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { setState(() {}); } + /// Prefer live provider maps so quick-add can autofill after lookups refresh. + Map get _itemUomById => + ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemUomById ?? + widget.itemUomById; + + Map get _itemGstRateById => + ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemGstRateById ?? + widget.itemGstRateById; + + Map get _itemHsnById => + ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemHsnById ?? + widget.itemHsnById; + + bool _fillMissingItemDefaults() { + final itemId = widget.line.itemId; + if (itemId == null) return false; + final key = itemId.toString(); + var changed = false; + + final defaultUom = _itemUomById[key]; + if (widget.line.uomId == null && defaultUom != null) { + widget.line.uomId = defaultUom; + changed = true; + } + final defaultGst = _itemGstRateById[key]; + if (widget.line.gstRateId == null && defaultGst != null) { + widget.line.gstRateId = defaultGst; + changed = true; + } + final defaultHsn = _itemHsnById[key]; + if (widget.line.hsnCodeId == null && defaultHsn != null) { + widget.line.hsnCodeId = defaultHsn; + changed = true; + } + return changed; + } + void _onItemChanged(int? itemId) { _updateLine(() { widget.line.itemId = itemId; if (itemId == null) return; final key = itemId.toString(); - final defaultUom = widget.itemUomById[key]; + final defaultUom = _itemUomById[key]; if (defaultUom != null) { widget.line.uomId = defaultUom; } - final defaultGst = widget.itemGstRateById[key]; + final defaultGst = _itemGstRateById[key]; if (defaultGst != null) { widget.line.gstRateId = defaultGst; } - final defaultHsn = widget.itemHsnById[key]; + final defaultHsn = _itemHsnById[key]; if (defaultHsn != null) { widget.line.hsnCodeId = defaultHsn; } }); } + @override + void didUpdateWidget(covariant _LineItemCard oldWidget) { + super.didUpdateWidget(oldWidget); + final mapsChanged = oldWidget.itemUomById != widget.itemUomById || + oldWidget.itemGstRateById != widget.itemGstRateById || + oldWidget.itemHsnById != widget.itemHsnById; + if (!mapsChanged) return; + if (!_fillMissingItemDefaults()) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.onChanged(); + setState(() {}); + }); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -467,8 +519,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { hint: 'Select item', searchHint: 'Search item name or code...', options: itemOptions, - refreshLookups: () => - ref.invalidate(purchaseOrderLookupsProvider), + refreshLookups: () async { + ref.invalidate(purchaseOrderLookupsProvider); + await ref.read(purchaseOrderLookupsProvider.future); + }, parseCreatedId: int.tryParse, onChanged: _onItemChanged, validator: (v) => v == null ? 'Item is required' : null, @@ -500,8 +554,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { hint: 'Select UOM', searchHint: 'Search UOM...', options: uomOptions, - refreshLookups: () => - ref.invalidate(purchaseOrderLookupsProvider), + refreshLookups: () async { + ref.invalidate(purchaseOrderLookupsProvider); + await ref.read(purchaseOrderLookupsProvider.future); + }, parseCreatedId: int.tryParse, onChanged: (v) => _updateLine(() => line.uomId = v), validator: (v) => v == null ? 'Required' : null, @@ -543,8 +599,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { hint: 'Select', searchHint: 'Search GST %...', options: gstOptions, - refreshLookups: () => - ref.invalidate(purchaseOrderLookupsProvider), + refreshLookups: () async { + ref.invalidate(purchaseOrderLookupsProvider); + await ref.read(purchaseOrderLookupsProvider.future); + }, parseCreatedId: int.tryParse, onChanged: (v) => _updateLine(() => line.gstRateId = v), ), diff --git a/lib/modules/rbac/presentation/providers/add_user_form_provider.dart b/lib/modules/rbac/presentation/providers/add_user_form_provider.dart index 19f07ef..7c58bcf 100644 --- a/lib/modules/rbac/presentation/providers/add_user_form_provider.dart +++ b/lib/modules/rbac/presentation/providers/add_user_form_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../roles/data/repositories/role_repository_impl.dart'; @@ -67,11 +69,21 @@ class AddUserFormNotifier extends FamilyAsyncNotifier final plants = await masterRemote.listPlants(); final designations = await masterRemote.listDesignations(); - final usersResult = await getUsers(const UserListQuery(limit: 100)); + final usersResult = await getUsers( + const UserListQuery( + limit: AppConstants.maxPageSize, + status: 'active', + isActive: true, + ), + ); if (usersResult.failure != null) throw usersResult.failure!; final managers = usersResult.data!.items - .where((user) => isReportingManagerRole(user.roleName)) + .where( + (user) => + isReportingManagerRole(user.roleName) && + isActiveUserOption(status: user.status, isActive: user.isActive), + ) .map((user) => FilterOptionModel(id: user.id, name: user.fullName)) .toList(); diff --git a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart index 1fed7a0..c03d595 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -13,6 +13,7 @@ import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../users/presentation/widgets/user_rich_data_table.dart'; import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_data_table.dart'; @@ -715,44 +716,38 @@ class _UsersTabState extends ConsumerState<_UsersTab> { children: [ Padding( padding: const EdgeInsets.all(16), - child: LayoutBuilder( - builder: (context, constraints) { - final useWrappedFilters = constraints.maxWidth < 1000; - return _UsersFilterBar( - wrapped: useWrappedFilters, - roleFilter: roleFilter, - departmentFilter: departmentFilter, - statusFilter: statusFilter, - roles: roles, - departments: departments, - statuses: statuses, - isExporting: usersState.isExporting, - showExport: canExport, - searchController: _searchController, - onExport: _exportUsers, - onSearch: ref.read(usersListProvider.notifier).setSearch, - onRoleChanged: (value) { - ref.read(usersListProvider.notifier).setRoleFilter( - value == 'All Roles' - ? null - : _roleIdForName(value, filters), - ); - }, - onDepartmentChanged: (value) { - ref.read(usersListProvider.notifier).setDepartmentFilter( - value == 'All Departments' - ? null - : _departmentIdForName(value, filters), - ); - }, - onStatusChanged: (value) { - ref.read(usersListProvider.notifier).setStatusFilter( - value == 'All Statuses' - ? null - : _statusValueForLabel(value, filters), - ); - }, - ); + child: _UsersFilterBar( + roleFilter: roleFilter, + departmentFilter: departmentFilter, + statusFilter: statusFilter, + roles: roles, + departments: departments, + statuses: statuses, + isExporting: usersState.isExporting, + showExport: canExport, + searchController: _searchController, + onExport: _exportUsers, + onSearch: ref.read(usersListProvider.notifier).setSearch, + onRoleChanged: (value) { + ref.read(usersListProvider.notifier).setRoleFilter( + value == 'All Roles' + ? null + : _roleIdForName(value, filters), + ); + }, + onDepartmentChanged: (value) { + ref.read(usersListProvider.notifier).setDepartmentFilter( + value == 'All Departments' + ? null + : _departmentIdForName(value, filters), + ); + }, + onStatusChanged: (value) { + ref.read(usersListProvider.notifier).setStatusFilter( + value == 'All Statuses' + ? null + : _statusValueForLabel(value, filters), + ); }, ), ), @@ -807,7 +802,6 @@ class _UsersTabState extends ConsumerState<_UsersTab> { class _UsersFilterBar extends StatelessWidget { const _UsersFilterBar({ - required this.wrapped, required this.roleFilter, required this.departmentFilter, required this.statusFilter, @@ -824,7 +818,6 @@ class _UsersFilterBar extends StatelessWidget { required this.onStatusChanged, }); - final bool wrapped; final String roleFilter; final String departmentFilter; final String statusFilter; @@ -878,56 +871,26 @@ class _UsersFilterBar extends StatelessWidget { ), ]; - final exportButton = Padding( - padding: const EdgeInsets.only(top: 8), - child: OutlinedButton.icon( - onPressed: isExporting ? null : onExport, - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 48), - padding: const EdgeInsets.symmetric(horizontal: 16), - ), - icon: isExporting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(isExporting ? 'Exporting...' : 'Export'), - ), - ); - - if (wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - ...filters.expand((f) => [f, const SizedBox(height: 12)]).toList() - ..removeLast(), - if (showExport) ...[ - const SizedBox(height: 12), - exportButton, - ], - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(flex: 3, child: searchField), - const SizedBox(width: 12), - Expanded(flex: 2, child: filters[0]), - const SizedBox(width: 12), - Expanded(flex: 2, child: filters[1]), - const SizedBox(width: 12), - Expanded(child: filters[2]), - if (showExport) ...[ - const SizedBox(width: 12), - exportButton, - ], - ], + return AppResponsiveFilterBar( + search: searchField, + filters: filters, + trailing: showExport + ? OutlinedButton.icon( + onPressed: isExporting ? null : onExport, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), + ) + : null, ); } } @@ -1314,7 +1277,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Role permission matrix', + 'Role Permission Matrix', style: Theme.of(context) .textTheme .titleMedium @@ -1426,7 +1389,7 @@ class _PermissionMatrixTable extends ConsumerWidget { AppDataColumn( label: 'Module', flex: 3, - searchText: (module) => '${module.name} ${module.code}', + enableSearch: false, cellBuilder: (context, module) { final index = matrix.modules.indexOf(module); final appearance = @@ -1465,10 +1428,7 @@ class _PermissionMatrixTable extends ConsumerWidget { label: permissionActionLabel(action), flex: 1, alignment: Alignment.center, - searchText: (module) { - final checked = module.granted[action] ?? false; - return checked ? 'yes granted true' : 'no denied false'; - }, + enableSearch: false, cellBuilder: (context, module) { final checked = module.granted[action] ?? false; return Checkbox( diff --git a/lib/modules/reports/domain/entities/depreciation_report.dart b/lib/modules/reports/domain/entities/depreciation_report.dart index 2818176..3e140b8 100644 --- a/lib/modules/reports/domain/entities/depreciation_report.dart +++ b/lib/modules/reports/domain/entities/depreciation_report.dart @@ -1,3 +1,4 @@ +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/asset_model.dart'; class ReportFilterOption { @@ -65,6 +66,10 @@ class DepreciationReportFilters { final raw = json[key]; if (raw is List && raw.isNotEmpty) { return raw + .where((item) { + if (item is! Map) return true; + return isActiveOptionRow(Map.from(item)); + }) .map(ReportFilterOption.fromDynamic) .where((o) => o.value.isNotEmpty) .toList(); diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index 09122d8..55bb361 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -18,10 +18,12 @@ import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_filter_date_field.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/widgets/app_search_field.dart'; import '../../domain/entities/depreciation_report.dart'; import '../providers/depreciation_report_provider.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -158,7 +160,7 @@ class _DepreciationReportScreenState ) : const Icon(Icons.download_outlined), label: Text( - state.isExporting ? 'Exporting...' : 'Export CSV', + state.isExporting ? 'Exporting...' : 'Export', ), ), ], @@ -167,32 +169,27 @@ class _DepreciationReportScreenState const SizedBox(height: 12), Expanded( child: AppTableShell( - toolbar: LayoutBuilder( - builder: (context, constraints) { - return _FiltersBar( - searchController: _searchController, - filters: state.filters, - query: state.query, - wrapped: constraints.maxWidth < 1100, - onSearch: notifier.setSearch, - onPlantChanged: notifier.setPlantId, - onCategoryChanged: notifier.setItemCategoryId, - onSubcategoryChanged: notifier.setItemSubcategoryId, - onDepartmentChanged: notifier.setDepartmentId, - onStatusChanged: notifier.setStatus, - onMethodChanged: notifier.setDepreciationMethod, - onIsActiveChanged: notifier.setIsActive, - onPickAsOfDate: () => _pickAsOfDate(state.query), - onClearAsOfDate: () => notifier.setAsOfDate(null), - onPickPurchaseRange: () => - _pickPurchaseDateRange(state.query), - onClearPurchaseRange: () => - notifier.setPurchaseDateRange(null, null), - onReset: () { - _searchController.clear(); - notifier.resetFilters(); - }, - ); + toolbar: _FiltersBar( + searchController: _searchController, + filters: state.filters, + query: state.query, + onSearch: notifier.setSearch, + onPlantChanged: notifier.setPlantId, + onCategoryChanged: notifier.setItemCategoryId, + onSubcategoryChanged: notifier.setItemSubcategoryId, + onDepartmentChanged: notifier.setDepartmentId, + onStatusChanged: notifier.setStatus, + onMethodChanged: notifier.setDepreciationMethod, + onIsActiveChanged: notifier.setIsActive, + onPickAsOfDate: () => _pickAsOfDate(state.query), + onClearAsOfDate: () => notifier.setAsOfDate(null), + onPickPurchaseRange: () => + _pickPurchaseDateRange(state.query), + onClearPurchaseRange: () => + notifier.setPurchaseDateRange(null, null), + onReset: () { + _searchController.clear(); + notifier.resetFilters(); }, ), footer: AppPagination( @@ -326,7 +323,6 @@ class _FiltersBar extends StatefulWidget { required this.searchController, required this.filters, required this.query, - required this.wrapped, required this.onSearch, required this.onPlantChanged, required this.onCategoryChanged, @@ -345,7 +341,6 @@ class _FiltersBar extends StatefulWidget { final TextEditingController searchController; final DepreciationReportFilters filters; final DepreciationReportQuery query; - final bool wrapped; final ValueChanged onSearch; final ValueChanged onPlantChanged; final ValueChanged onCategoryChanged; @@ -399,24 +394,11 @@ class _FiltersBarState extends State<_FiltersBar> { .where((o) => o.parentId == query.itemCategoryId) .toList(); - final searchField = TextField( + final searchField = AppSearchField( controller: widget.searchController, + hint: 'Search asset code or name...', onChanged: widget.onSearch, - decoration: InputDecoration( - labelText: 'Search', - hintText: 'Search asset code or name...', - prefixIcon: const Icon(Icons.search), - isDense: true, - suffixIcon: widget.searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - widget.searchController.clear(); - widget.onSearch(''); - }, - ) - : null, - ), + onClear: () => widget.onSearch(''), ); Widget dropdown({ @@ -536,19 +518,6 @@ class _FiltersBarState extends State<_FiltersBar> { child: const Text('Reset'), ); - Widget row(List cells) { - assert(cells.length == 4); - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - for (var i = 0; i < cells.length; i++) ...[ - if (i > 0) const SizedBox(width: 12), - Expanded(child: cells[i]), - ], - ], - ); - } - final actions = Row( children: [ moreButton, @@ -556,58 +525,36 @@ class _FiltersBarState extends State<_FiltersBar> { ], ); - if (widget.wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - plant, - const SizedBox(height: 12), - category, - const SizedBox(height: 12), - asOfField, - const SizedBox(height: 4), - actions, - if (_moreOpen) ...[ - const SizedBox(height: 8), - Divider(color: theme.colorScheme.outline.withValues(alpha: 0.2)), - const SizedBox(height: 8), - subcategory, - const SizedBox(height: 12), - department, - const SizedBox(height: 12), - status, - const SizedBox(height: 12), - method, - const SizedBox(height: 12), - active, - const SizedBox(height: 12), - purchaseField, - ], - ], - ); - } + final moreFilters = _moreOpen + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Divider( + height: 1, + color: theme.colorScheme.outline.withValues(alpha: 0.2), + ), + const SizedBox(height: 12), + AppResponsiveFilterGrid( + fields: [ + subcategory, + department, + status, + method, + active, + purchaseField, + ], + ), + ], + ) + : null; - return Column( - children: [ - row([searchField, plant, category, asOfField]), - const SizedBox(height: 4), - Align(alignment: Alignment.centerRight, child: actions), - if (_moreOpen) ...[ - const SizedBox(height: 8), - Divider(height: 1, color: theme.colorScheme.outline.withValues(alpha: 0.2)), - const SizedBox(height: 12), - row([subcategory, department, status, method]), - const SizedBox(height: 12), - row([ - active, - purchaseField, - const SizedBox.shrink(), - const SizedBox.shrink(), - ]), - ], - ], + return AppResponsiveFilterGrid( + fields: [searchField, plant, category, asOfField], + footer: Align( + alignment: Alignment.centerRight, + child: actions, + ), + extra: moreFilters, ); } } @@ -664,26 +611,6 @@ class _ReportTable extends StatelessWidget { textAlign: TextAlign.right, ), ), - AppDataColumn( - label: 'Method', - flex: 2, - searchText: (row) => row.depreciationMethod ?? '', - cellBuilder: (_, row) => AppTableCell.text( - row.depreciationMethod, - placeholder: '-', - ), - ), - AppDataColumn( - label: 'Rate %', - flex: 1, - alignment: Alignment.centerRight, - searchText: (row) => row.depreciationRate?.toStringAsFixed(2) ?? '', - cellBuilder: (_, row) => AppTableCell.text( - row.depreciationRate?.toStringAsFixed(2), - textAlign: TextAlign.right, - placeholder: '-', - ), - ), AppDataColumn( label: 'Annual', flex: 2, diff --git a/lib/modules/roles/data/datasources/role_remote_data_source.dart b/lib/modules/roles/data/datasources/role_remote_data_source.dart index 3166e74..6c062d7 100644 --- a/lib/modules/roles/data/datasources/role_remote_data_source.dart +++ b/lib/modules/roles/data/datasources/role_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/permission_matrix_models.dart'; import '../../../../shared/models/role_model.dart'; import '../../../../shared/models/user_management_models.dart'; @@ -42,13 +43,25 @@ class RoleRemoteDataSource { if (search != null && search.isNotEmpty) 'search': search, }, ); - return _parseList( - response.data['data'], - (json) => FilterOptionModel( - id: json['id']?.toString() ?? '', - name: json['name'] as String? ?? '', - ), - ).where((role) => role.id.isNotEmpty && role.name.isNotEmpty).toList(); + final raw = response.data['data']; + final list = raw is List + ? raw + : raw is Map + ? (raw['items'] as List?) ?? const [] + : const []; + + return list + .whereType() + .map((item) => Map.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 getRoleById(String id) async { diff --git a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart index add0a60..a74ad7e 100644 --- a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart +++ b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart @@ -91,7 +91,7 @@ class _MatrixGrid extends ConsumerWidget { AppDataColumn( label: 'Module', flex: 3, - searchText: (row) => row.name, + enableSearch: false, cellBuilder: (_, row) => AppTableCell.text(row.name), ), ...matrix.actionColumns.map( @@ -99,10 +99,7 @@ class _MatrixGrid extends ConsumerWidget { label: permissionActionLabel(action), flex: 1, alignment: Alignment.center, - searchText: (row) { - final checked = row.granted[action] ?? false; - return checked ? 'yes granted true' : 'no denied false'; - }, + enableSearch: false, cellBuilder: (_, row) => Checkbox( value: row.granted[action] ?? false, visualDensity: VisualDensity.compact, diff --git a/lib/modules/settings/presentation/widgets/settings_widgets.dart b/lib/modules/settings/presentation/widgets/settings_widgets.dart index ff6e53c..4946778 100644 --- a/lib/modules/settings/presentation/widgets/settings_widgets.dart +++ b/lib/modules/settings/presentation/widgets/settings_widgets.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../../core/utils/responsive_utils.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/page_header.dart'; import '../../domain/entities/app_settings.dart'; @@ -117,10 +118,9 @@ class SettingsSwitchTile extends StatelessWidget { @override Widget build(BuildContext context) { - return SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, + return AppFormToggleField( + label: title, + subtitle: subtitle, value: value, onChanged: onChanged, ); diff --git a/lib/modules/users/data/datasources/user_remote_data_source.dart b/lib/modules/users/data/datasources/user_remote_data_source.dart index 14a2b93..d3cbcf7 100644 --- a/lib/modules/users/data/datasources/user_remote_data_source.dart +++ b/lib/modules/users/data/datasources/user_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/user_management_models.dart'; @@ -31,9 +32,11 @@ class UserRemoteDataSource { List parseIdNameOptions(List? list) { return (list ?? []) - .map( - (item) => FilterOptionModel.fromJson(item as Map), - ) + .whereType() + .map((item) => Map.from(item)) + .where(isActiveOptionRow) + .map(FilterOptionModel.fromJson) + .where((item) => item.id.isNotEmpty && item.name.isNotEmpty) .toList(); } diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index 5870678..54edb4e 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -12,6 +12,7 @@ import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_status_chip.dart'; @@ -272,29 +273,9 @@ class _FiltersBar extends StatelessWidget { ), ]; - if (context.isMobile) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - ...dropdowns.expand((f) => [f, const SizedBox(height: 12)]).toList() - ..removeLast(), - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(flex: 3, child: searchField), - const SizedBox(width: 12), - Expanded(flex: 2, child: dropdowns[0]), - const SizedBox(width: 12), - Expanded(flex: 2, child: dropdowns[1]), - const SizedBox(width: 12), - Expanded(flex: 2, child: dropdowns[2]), - ], + return AppResponsiveFilterBar( + search: searchField, + filters: dropdowns, ); } } diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index 270d722..80bd60e 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -16,6 +16,7 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; @@ -90,22 +91,17 @@ class _VendorListScreenState extends ConsumerState { ), Expanded( child: AppTableShell( - toolbar: LayoutBuilder( - builder: (context, constraints) { - return _FiltersBar( - searchController: _searchController, - query: state.query, - wrapped: constraints.maxWidth < 900, - showExport: canExport, - isExporting: state.isExporting, - onExport: _exportVendors, - onSearch: ref.read(vendorsListProvider.notifier).setSearch, - onStatusChanged: - ref.read(vendorsListProvider.notifier).setStatusFilter, - onVendorTypeChanged: - ref.read(vendorsListProvider.notifier).setVendorTypeFilter, - ); - }, + toolbar: _FiltersBar( + searchController: _searchController, + query: state.query, + showExport: canExport, + isExporting: state.isExporting, + onExport: _exportVendors, + onSearch: ref.read(vendorsListProvider.notifier).setSearch, + onStatusChanged: + ref.read(vendorsListProvider.notifier).setStatusFilter, + onVendorTypeChanged: + ref.read(vendorsListProvider.notifier).setVendorTypeFilter, ), footer: AppPagination( currentPage: state.query.page, @@ -209,7 +205,6 @@ class _FiltersBar extends StatelessWidget { const _FiltersBar({ required this.searchController, required this.query, - required this.wrapped, required this.onSearch, required this.onStatusChanged, required this.onVendorTypeChanged, @@ -220,7 +215,6 @@ class _FiltersBar extends StatelessWidget { final TextEditingController searchController; final VendorListQuery query; - final bool wrapped; final ValueChanged onSearch; final ValueChanged onStatusChanged; final ValueChanged onVendorTypeChanged; @@ -265,48 +259,22 @@ class _FiltersBar extends StatelessWidget { ), ]; - final exportButton = OutlinedButton.icon( - onPressed: isExporting ? null : onExport, - icon: isExporting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), + return AppResponsiveFilterBar( + search: searchField, + filters: filters, + trailing: showExport + ? OutlinedButton.icon( + onPressed: isExporting ? null : onExport, + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(isExporting ? 'Exporting...' : 'Export'), - ); - - if (wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - const SizedBox(height: 12), - filters[0], - const SizedBox(height: 12), - filters[1], - if (showExport) ...[ - const SizedBox(height: 12), - Align(alignment: Alignment.centerRight, child: exportButton), - ], - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(flex: 3, child: searchField), - const SizedBox(width: 12), - Expanded(flex: 2, child: filters[0]), - const SizedBox(width: 12), - Expanded(flex: 2, child: filters[1]), - if (showExport) ...[ - const SizedBox(width: 12), - exportButton, - ], - ], + : null, ); } } diff --git a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart index 7b13722..364e1d3 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart @@ -7,6 +7,7 @@ import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/vendor_model.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; @@ -329,9 +330,8 @@ class _VendorFormPanelState extends ConsumerState { ), if (_showActiveSwitch) ...[ const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Active'), + AppFormToggleField( + label: 'Active', value: _isActive, onChanged: (v) => setState(() => _isActive = v), ), diff --git a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart index 89007ad..8a0affb 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart @@ -5,6 +5,7 @@ import '../../../../core/utils/validators.dart'; import '../../../../shared/models/vendor_model.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../providers/vendors_provider.dart'; @@ -196,9 +197,8 @@ class _VendorAddressPanelState extends ConsumerState { inputFormatters: Validators.gstinInput, ), const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Active'), + AppFormToggleField( + label: 'Active', value: _isActive, onChanged: (v) => setState(() => _isActive = v), ), @@ -325,15 +325,13 @@ class _VendorContactPanelState extends ConsumerState { ), ), const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Primary Contact'), + AppFormToggleField( + label: 'Primary Contact', value: _isPrimary, onChanged: (v) => setState(() => _isPrimary = v), ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Active'), + AppFormToggleField( + label: 'Active', value: _isActive, onChanged: (v) => setState(() => _isActive = v), ), @@ -491,15 +489,13 @@ class _VendorBankDetailPanelState extends ConsumerState { onChanged: (v) => setState(() => _accountType = v), ), const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Primary Account'), + AppFormToggleField( + label: 'Primary Account', value: _isPrimary, onChanged: (v) => setState(() => _isPrimary = v), ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Active'), + AppFormToggleField( + label: 'Active', value: _isActive, onChanged: (v) => setState(() => _isActive = v), ), diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index f911f3b..5a87925 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -42,12 +42,6 @@ const List appMenuItems = [ // route: RouteConstants.branches, // module: 'branches', // ), - MenuItem( - label: 'Users & Roles', - icon: Icons.admin_panel_settings_outlined, - route: RouteConstants.usersRoleManagement, - module: 'users', - ), MenuItem( label: 'Assets', icon: Icons.inventory_2_outlined, @@ -86,18 +80,24 @@ const List appMenuItems = [ route: RouteConstants.grn, module: 'grn', ), - MenuItem( - label: 'Master Data', - icon: Icons.dataset_outlined, - route: RouteConstants.masterData, - module: 'master_data', - ), MenuItem( label: 'Reports', icon: Icons.assessment_outlined, route: RouteConstants.reports, module: 'reports', ), + MenuItem( + label: 'Users & Roles', + icon: Icons.admin_panel_settings_outlined, + route: RouteConstants.usersRoleManagement, + module: 'users', + ), + MenuItem( + label: 'Master Data', + icon: Icons.dataset_outlined, + route: RouteConstants.masterData, + module: 'master_data', + ), MenuItem( label: 'Audit Logs', icon: Icons.history_outlined, @@ -112,6 +112,13 @@ const List appMenuItems = [ ), ]; +/// Routes rendered under the Support section (after primary ops menus). +const Set supportMenuRoutes = { + RouteConstants.usersRoleManagement, + RouteConstants.masterData, + RouteConstants.auditLogs, + RouteConstants.settings, +}; List getVisibleMenuItems({ required List permissions, required UserRole role, diff --git a/lib/shared/widgets/app_filter_date_field.dart b/lib/shared/widgets/app_filter_date_field.dart index 9d62623..baf81a9 100644 --- a/lib/shared/widgets/app_filter_date_field.dart +++ b/lib/shared/widgets/app_filter_date_field.dart @@ -13,6 +13,7 @@ class AppFilterDateField extends StatelessWidget { this.placeholder = 'Select date', this.onClear, this.isEmpty = false, + this.alignWithLabeledFields = true, }); final String label; @@ -22,6 +23,7 @@ class AppFilterDateField extends StatelessWidget { final String placeholder; final VoidCallback? onClear; final bool isEmpty; + final bool alignWithLabeledFields; static const _suffixConstraints = BoxConstraints( minWidth: 40, @@ -35,7 +37,7 @@ class AppFilterDateField extends StatelessWidget { final theme = Theme.of(context); final showClear = onClear != null && !isEmpty; - return InputDecorator( + final field = InputDecorator( decoration: InputDecoration( labelText: label, isDense: true, @@ -79,5 +81,11 @@ class AppFilterDateField extends StatelessWidget { ), ), ); + + if (!alignWithLabeledFields) return field; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: field, + ); } } diff --git a/lib/shared/widgets/app_form_toggle_field.dart b/lib/shared/widgets/app_form_toggle_field.dart new file mode 100644 index 0000000..174e61f --- /dev/null +++ b/lib/shared/widgets/app_form_toggle_field.dart @@ -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? 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/app_responsive_filter_bar.dart b/lib/shared/widgets/app_responsive_filter_bar.dart new file mode 100644 index 0000000..835fc39 --- /dev/null +++ b/lib/shared/widgets/app_responsive_filter_bar.dart @@ -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 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 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; + } +} diff --git a/lib/shared/widgets/app_search_export_bar.dart b/lib/shared/widgets/app_search_export_bar.dart index a4a0b61..69caa12 100644 --- a/lib/shared/widgets/app_search_export_bar.dart +++ b/lib/shared/widgets/app_search_export_bar.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'app_responsive_filter_bar.dart'; + /// Search field with export action — matches the users list toolbar (no filters). class AppSearchExportBar extends StatelessWidget { const AppSearchExportBar({ @@ -8,6 +10,7 @@ class AppSearchExportBar extends StatelessWidget { required this.onSearch, required this.onExport, this.isExporting = false, + @Deprecated('Layout is always responsive via AppResponsiveFilterBar') this.wrapped = false, this.searchController, this.searchWidth = 320, @@ -35,42 +38,21 @@ class AppSearchExportBar extends StatelessWidget { onChanged: onSearch, ); - final exportButton = OutlinedButton.icon( - onPressed: isExporting ? null : onExport, - icon: isExporting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), + return AppResponsiveFilterBar( + search: searchField, + trailing: showExport + ? OutlinedButton.icon( + onPressed: isExporting ? null : onExport, + icon: isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(isExporting ? 'Exporting...' : 'Export'), ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(isExporting ? 'Exporting...' : 'Export'), - ); - - if (wrapped) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - searchField, - if (showExport) ...[ - const SizedBox(height: 12), - Row( - children: [ - const Spacer(), - exportButton, - ], - ), - ], - ], - ); - } - - return Row( - children: [ - SizedBox(width: searchWidth, child: searchField), - const Spacer(), - if (showExport) exportButton, - ], + : null, ); } } diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index 217d54b..6675b10 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -230,6 +230,7 @@ class _AppSearchableDropdownState extends State> { decoration: InputDecoration( labelText: widget.label, hintText: displayLabel == null ? effectiveHint : null, + hintMaxLines: 1, floatingLabelBehavior: FloatingLabelBehavior.always, isDense: widget.isDense, errorText: field.errorText, @@ -612,6 +613,7 @@ class _AppSearchableLookupFieldState decoration: InputDecoration( labelText: widget.label, hintText: displayLabel == null ? effectiveHint : null, + hintMaxLines: 1, floatingLabelBehavior: FloatingLabelBehavior.always, isDense: widget.isDense, suffixIcon: Icon( diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index d5fc96e..04d285b 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -57,8 +57,16 @@ class _AppSidebarState extends ConsumerState { final Set _expandedMenus = {}; final Set _manuallyCollapsedMenus = {}; - List get _mainMenuItems => - widget.menuItems.where((item) => item.route != RouteConstants.settings).toList(); + List get _primaryMenuItems => widget.menuItems + .where((item) => !menu.supportMenuRoutes.contains(item.route)) + .toList(); + + List get _supportMenuItems => widget.menuItems + .where((item) => menu.supportMenuRoutes.contains(item.route)) + .toList(); + + bool get _hasSupportSection => + _supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu; bool _routeMatches(String route) { final current = widget.currentRoute; @@ -104,7 +112,7 @@ class _AppSidebarState extends ConsumerState { void didUpdateWidget(covariant AppSidebar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.currentRoute != widget.currentRoute) { - for (final item in _mainMenuItems) { + for (final item in [..._primaryMenuItems, ..._supportMenuItems]) { if (item.children.isNotEmpty && _isGroupActive(item)) { _expandedMenus.add(item.route); _manuallyCollapsedMenus.remove(item.route); @@ -167,84 +175,25 @@ class _AppSidebarState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!isNarrow) - const _SectionLabel(label: 'MAIN MENU') - else - const SizedBox(height: 4), - ..._mainMenuItems.map((item) { - if (isNarrow && item.children.isNotEmpty) { - return _CollapsedFlyoutNavItem( - item: item, - selected: _isGroupActive(item), - isChildSelected: (route) => - _isSelectedAmongSiblings(route, item.children), - onChildTap: widget.onItemTap, - ); - } - - if (item.children.isNotEmpty && !isNarrow) { - final expanded = _isGroupExpanded(item); - final active = _isGroupActive(item); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _SidebarNavItem( - icon: item.icon, - label: item.label, - selected: active, - collapsed: false, - showChevron: true, - chevronExpanded: expanded, - onTap: () => _toggleGroup(item), - ), - if (expanded) - ...item.children.map( - (child) => _SidebarNavItem( - icon: child.icon, - label: child.label, - selected: _isSelectedAmongSiblings( - child.route, - item.children, - ), - collapsed: false, - indent: _sidebarChildIndent, - onTap: () => widget.onItemTap(child.route), - ), - ), - ], - ); - } - - return _SidebarNavItem( - icon: item.icon, - label: item.label, - selected: _isSelected(item.route) || - (isNarrow && _isGroupActive(item)), - collapsed: isNarrow, - onTap: () { - widget.onItemTap(item.route); - }, - ); - }), - const SizedBox(height: 16), - if (!isNarrow) const _SectionLabel(label: 'SUPPORT'), - if (AppConstants.showNotificationsMenu) - _SidebarNavItem( - icon: Icons.notifications_outlined, - label: 'Notifications', - selected: false, - collapsed: isNarrow, - badge: isNarrow ? null : '3', - onTap: () {}, - ), - if (_hasSettings) - _SidebarNavItem( - icon: Icons.settings_outlined, - label: 'Settings', - selected: _isSelected(RouteConstants.settings), - collapsed: isNarrow, - onTap: () => widget.onItemTap(RouteConstants.settings), + if (isNarrow) const SizedBox(height: 4), + ..._buildNavItems(_primaryMenuItems, isNarrow: isNarrow), + if (_hasSupportSection) ...[ + const SizedBox(height: 16), + if (!isNarrow) const _SectionLabel(label: 'SUPPORT'), + if (AppConstants.showNotificationsMenu) + _SidebarNavItem( + icon: Icons.notifications_outlined, + label: 'Notifications', + selected: false, + collapsed: isNarrow, + badge: isNarrow ? null : '3', + onTap: () {}, + ), + ..._buildNavItems( + _supportMenuItems, + isNarrow: isNarrow, ), + ], ], ), ), @@ -259,8 +208,66 @@ class _AppSidebarState extends ConsumerState { ); } - bool get _hasSettings => - widget.menuItems.any((item) => item.route == RouteConstants.settings); + List _buildNavItems( + List items, { + required bool isNarrow, + }) { + return items.map((item) { + if (isNarrow && item.children.isNotEmpty) { + return _CollapsedFlyoutNavItem( + item: item, + selected: _isGroupActive(item), + isChildSelected: (route) => + _isSelectedAmongSiblings(route, item.children), + onChildTap: widget.onItemTap, + ); + } + + if (item.children.isNotEmpty && !isNarrow) { + final expanded = _isGroupExpanded(item); + final active = _isGroupActive(item); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SidebarNavItem( + icon: item.icon, + label: item.label, + selected: active, + collapsed: false, + showChevron: true, + chevronExpanded: expanded, + onTap: () => _toggleGroup(item), + ), + if (expanded) + ...item.children.map( + (child) => _SidebarNavItem( + icon: child.icon, + label: child.label, + selected: _isSelectedAmongSiblings( + child.route, + item.children, + ), + collapsed: false, + indent: _sidebarChildIndent, + onTap: () => widget.onItemTap(child.route), + ), + ), + ], + ); + } + + return _SidebarNavItem( + icon: item.icon, + label: item.label, + selected: _isSelected(item.route) || + (isNarrow && _isGroupActive(item)), + collapsed: isNarrow, + onTap: () { + widget.onItemTap(item.route); + }, + ); + }).toList(); + } Widget _buildHeader(BuildContext context, {required bool isNarrow}) { final theme = Theme.of(context); diff --git a/lib/shared/widgets/master_inline_quick_add_form.dart b/lib/shared/widgets/master_inline_quick_add_form.dart index 6755cb2..6b9a52c 100644 --- a/lib/shared/widgets/master_inline_quick_add_form.dart +++ b/lib/shared/widgets/master_inline_quick_add_form.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/utils/validators.dart'; import '../../modules/master_data/data/repositories/master_repository_impl.dart'; import '../../modules/master_data/domain/entities/master_definition.dart'; +import 'app_form_toggle_field.dart'; /// Compact master create form for use inside a dropdown panel. class MasterInlineQuickAddForm extends ConsumerStatefulWidget { @@ -176,10 +177,8 @@ class _MasterInlineQuickAddFormState switch (field.type) { case MasterFieldType.boolean: if (field.key == 'is_active') { - return SwitchListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text(field.label, style: const TextStyle(fontSize: 13)), + return AppFormToggleField( + label: field.label, value: _values[field.key] == true, onChanged: _submitting ? null @@ -253,6 +252,9 @@ class _MasterInlineQuickAddFormState Widget build(BuildContext context) { final theme = Theme.of(context); final fields = _definition.formFields; + final activeField = fields.where((field) => field.key == 'is_active').firstOrNull; + final regularFields = + fields.where((field) => field.key != 'is_active').toList(); return Material( color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), @@ -297,7 +299,7 @@ class _MasterInlineQuickAddFormState ), ) else ...[ - for (final field in fields) ...[ + for (final field in regularFields) ...[ _buildField(field), const SizedBox(height: 8), ], @@ -312,25 +314,40 @@ class _MasterInlineQuickAddFormState ), ), Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Spacer(), - TextButton( - onPressed: _submitting ? null : widget.onCancel, - child: const Text('Cancel'), - ), - const SizedBox(width: 4), - FilledButton( - onPressed: _submitting ? null : _submit, - child: _submitting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text('Add ${_definition.title}'), + if (activeField != null) + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: _buildField(activeField), + ), + ) + else + const Spacer(), + const SizedBox(width: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: _submitting ? null : widget.onCancel, + child: const Text('Cancel'), + ), + const SizedBox(width: 4), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text('Add ${_definition.title}'), + ), + ], ), ], ), diff --git a/lib/shared/widgets/sidebar_logo.dart b/lib/shared/widgets/sidebar_logo.dart index a34c849..40c456a 100644 --- a/lib/shared/widgets/sidebar_logo.dart +++ b/lib/shared/widgets/sidebar_logo.dart @@ -1,4 +1,7 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; @@ -31,6 +34,7 @@ class SidebarLogo extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isLight = theme.brightness == Brightness.light; final fallback = Image.asset( AppConstants.defaultLogoAsset, width: _width, @@ -43,7 +47,7 @@ class SidebarLogo extends StatelessWidget { ), ); - final content = _buildLogoContent(fallback); + final content = _buildLogoContent(fallback, knockOutBlack: isLight); if (!showBackground) { return SizedBox(width: _width, height: _height, child: content); @@ -61,39 +65,50 @@ class SidebarLogo extends StatelessWidget { ); } - Widget _buildLogoContent(Widget fallback) { + Widget _buildLogoContent(Widget fallback, {required bool knockOutBlack}) { final url = resolveMediaUrl(logoUrl); if (url == null || url.isEmpty) { - return fallback; + return _maybeKnockOut(fallbackProvider: const AssetImage(AppConstants.defaultLogoAsset), fallback: fallback, knockOutBlack: knockOutBlack); } if (url.startsWith('assets/')) { - return Image.asset( + final image = Image.asset( url, width: _width, height: _height, fit: fit, errorBuilder: (_, __, ___) => fallback, ); + return _maybeKnockOut( + fallbackProvider: AssetImage(url), + fallback: image, + knockOutBlack: knockOutBlack, + ); } if (url.startsWith('data:image')) { try { final base64Str = url.contains(',') ? url.split(',').last : url; - return Image.memory( - base64Decode(base64Str), + final bytes = base64Decode(base64Str); + final image = Image.memory( + bytes, width: _width, height: _height, fit: fit, errorBuilder: (_, __, ___) => fallback, ); + return _maybeKnockOut( + fallbackProvider: MemoryImage(bytes), + fallback: image, + knockOutBlack: knockOutBlack, + ); } catch (_) { return fallback; } } if (url.startsWith('http://') || url.startsWith('https://')) { - return CachedNetworkImage( + final image = CachedNetworkImage( imageUrl: url, width: _width, height: _height, @@ -107,10 +122,224 @@ class SidebarLogo extends StatelessWidget { ), errorWidget: (_, __, ___) => fallback, ); + return _maybeKnockOut( + fallbackProvider: CachedNetworkImageProvider(url), + fallback: image, + knockOutBlack: knockOutBlack, + ); } return fallback; } + + Widget _maybeKnockOut({ + required ImageProvider fallbackProvider, + required Widget fallback, + required bool knockOutBlack, + }) { + if (!knockOutBlack) return fallback; + return _BlackBackgroundKnockOut( + provider: fallbackProvider, + width: _width, + height: _height, + fit: fit, + fallback: fallback, + ); + } +} + +/// Removes a solid black logo backdrop in light mode when corners are black. +class _BlackBackgroundKnockOut extends StatefulWidget { + const _BlackBackgroundKnockOut({ + required this.provider, + required this.width, + required this.height, + required this.fit, + required this.fallback, + }); + + final ImageProvider provider; + final double width; + final double height; + final BoxFit fit; + final Widget fallback; + + @override + State<_BlackBackgroundKnockOut> createState() => + _BlackBackgroundKnockOutState(); +} + +class _BlackBackgroundKnockOutState extends State<_BlackBackgroundKnockOut> { + static final Map _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 _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 _resolveImage(ImageProvider provider) async { + final completer = Completer(); + 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 _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.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.