diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index b2fce5d..32476a2 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; class AppColors { AppColors._(); - static const Color primary = Color(0xFF1565C0); - static const Color secondary = Color(0xFF00897B); + static const Color primary = Color(0xFF2563EB); + static const Color secondary = Color(0xFF0891B2); static const Color error = Color(0xFFD32F2F); static const Color warning = Color(0xFFF57C00); static const Color success = Color(0xFF388E3C); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 83732a0..1c6bb02 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -12,8 +12,9 @@ class AppTheme { final primary = branding?.primaryColor ?? AppColors.primary; final secondary = branding?.secondaryColor ?? AppColors.secondary; - final colorScheme = ColorScheme.fromSeed( - seedColor: primary, + final colorScheme = _brandedColorScheme( + seed: primary, + primary: primary, secondary: secondary, brightness: Brightness.light, surface: AppColors.lightSurface, @@ -26,8 +27,9 @@ class AppTheme { final primary = branding?.primaryColor ?? AppColors.primary; final secondary = branding?.secondaryColor ?? AppColors.secondary; - final colorScheme = ColorScheme.fromSeed( - seedColor: primary, + final colorScheme = _brandedColorScheme( + seed: primary, + primary: primary, secondary: secondary, brightness: Brightness.dark, surface: AppColors.darkSurface, @@ -36,12 +38,56 @@ class AppTheme { return _buildTheme(colorScheme, Brightness.dark); } + /// Keeps Material tonal containers from [seed], but locks brand [primary] / + /// [secondary] to the exact chosen hex values (fromSeed remaps primary). + static ColorScheme _brandedColorScheme({ + required Color seed, + required Color primary, + required Color secondary, + required Brightness brightness, + required Color surface, + }) { + final base = ColorScheme.fromSeed( + seedColor: seed, + secondary: secondary, + brightness: brightness, + surface: surface, + ); + + final onPrimary = _onColor(primary); + final onSecondary = _onColor(secondary); + + return base.copyWith( + primary: primary, + onPrimary: onPrimary, + primaryContainer: Color.alphaBlend( + primary.withValues(alpha: 0.18), + surface, + ), + onPrimaryContainer: primary, + secondary: secondary, + onSecondary: onSecondary, + secondaryContainer: Color.alphaBlend( + secondary.withValues(alpha: 0.18), + surface, + ), + onSecondaryContainer: secondary, + ); + } + + static Color _onColor(Color color) { + return ThemeData.estimateBrightnessForColor(color) == Brightness.dark + ? Colors.white + : const Color(0xFF212121); + } + /// Theme for white cards, form fields, and picker sheets (unchanged in dark mode). static ThemeData cardContentTheme(ThemeData theme) { final scheme = theme.brightness == Brightness.light ? theme.colorScheme - : ColorScheme.fromSeed( - seedColor: theme.colorScheme.primary, + : _brandedColorScheme( + seed: theme.colorScheme.primary, + primary: theme.colorScheme.primary, secondary: theme.colorScheme.secondary, brightness: Brightness.light, surface: AppColors.card, diff --git a/lib/core/theme/branding_config.dart b/lib/core/theme/branding_config.dart index bbd07b2..ac8c1c0 100644 --- a/lib/core/theme/branding_config.dart +++ b/lib/core/theme/branding_config.dart @@ -8,8 +8,8 @@ part 'branding_config.g.dart'; class BrandingConfig with _$BrandingConfig { const factory BrandingConfig({ String? logoUrl, - @Default(0xFF1565C0) int primaryColorValue, - @Default(0xFF00897B) int secondaryColorValue, + @Default(0xFF2563EB) int primaryColorValue, + @Default(0xFF0891B2) int secondaryColorValue, String? companyName, }) = _BrandingConfig; diff --git a/lib/core/theme/branding_config.freezed.dart b/lib/core/theme/branding_config.freezed.dart index 506745b..525d318 100644 --- a/lib/core/theme/branding_config.freezed.dart +++ b/lib/core/theme/branding_config.freezed.dart @@ -159,8 +159,8 @@ class __$$BrandingConfigImplCopyWithImpl<$Res> class _$BrandingConfigImpl implements _BrandingConfig { const _$BrandingConfigImpl({ this.logoUrl, - this.primaryColorValue = 0xFF1565C0, - this.secondaryColorValue = 0xFF00897B, + this.primaryColorValue = 0xFF2563EB, + this.secondaryColorValue = 0xFF0891B2, this.companyName, }); diff --git a/lib/core/theme/branding_config.g.dart b/lib/core/theme/branding_config.g.dart index 119e4cd..d08eeda 100644 --- a/lib/core/theme/branding_config.g.dart +++ b/lib/core/theme/branding_config.g.dart @@ -10,9 +10,9 @@ _$BrandingConfigImpl _$$BrandingConfigImplFromJson(Map json) => _$BrandingConfigImpl( logoUrl: json['logoUrl'] as String?, primaryColorValue: - (json['primaryColorValue'] as num?)?.toInt() ?? 0xFF1565C0, + (json['primaryColorValue'] as num?)?.toInt() ?? 0xFF2563EB, secondaryColorValue: - (json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF00897B, + (json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF0891B2, companyName: json['companyName'] as String?, ); diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart index d054ad9..c382569 100644 --- a/lib/core/utils/formatters.dart +++ b/lib/core/utils/formatters.dart @@ -58,3 +58,47 @@ class CurrencyFormatter { return _formatter.format(amount); } } + +/// Converts `snake_case` / `kebab-case` keys into readable Title Case labels. +String humanizeLabel(String key) { + final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' '); + if (cleaned.isEmpty) return key; + + const acronyms = { + 'id': 'ID', + 'amc': 'AMC', + 'gst': 'GST', + 'hsn': 'HSN', + 'uom': 'UOM', + 'po': 'PO', + 'grn': 'GRN', + 'url': 'URL', + 'api': 'API', + }; + + return cleaned + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .map((part) { + final lower = part.toLowerCase(); + if (acronyms.containsKey(lower)) return acronyms[lower]!; + return '${lower[0].toUpperCase()}${lower.substring(1)}'; + }) + .join(' '); +} + +/// Subject used in dropdown hints (`Select …` / `Search …`). +/// Strips required asterisks and title-cases each word. +String dropdownHintLabel(String label) { + final cleaned = + label.replaceAll('*', '').trim().replaceAll(RegExp(r'\s+'), ' '); + if (cleaned.isEmpty) return label.trim(); + + return cleaned.split(' ').map((word) { + if (word.isEmpty) return word; + // Keep short all-caps tokens (GST, UOM, PO…). + if (word.length <= 4 && word == word.toUpperCase()) return word; + final lower = word.toLowerCase(); + return '${lower[0].toUpperCase()}${lower.substring(1)}'; + }).join(' '); +} diff --git a/lib/core/utils/pagination_meta.dart b/lib/core/utils/pagination_meta.dart new file mode 100644 index 0000000..2e7c3d6 --- /dev/null +++ b/lib/core/utils/pagination_meta.dart @@ -0,0 +1,102 @@ +/// Shared helpers for list API pagination meta. +library; + +int? paginationInt(dynamic value) { + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value.trim()); + return null; +} + +/// Prefer nested `meta`, then fields on `data` when it is a map, then top-level. +Map extractPaginationMeta(dynamic body) { + if (body is! Map) return const {}; + + final root = Map.from(body); + final meta = {}; + + final topMeta = root['meta']; + if (topMeta is Map) { + meta.addAll(Map.from(topMeta)); + } + + final data = root['data']; + if (data is Map) { + final nested = Map.from(data); + for (final key in const [ + 'page', + 'limit', + 'per_page', + 'page_size', + 'total', + 'totalPages', + 'total_pages', + ]) { + if (nested[key] != null) meta[key] = nested[key]; + } + } + + for (final key in const [ + 'page', + 'limit', + 'per_page', + 'page_size', + 'total', + 'totalPages', + 'total_pages', + ]) { + if (meta[key] == null && root[key] != null) { + meta[key] = root[key]; + } + } + + return meta; +} + +/// Page count from total rows and page size. Never trust a bad API `total_pages`. +int resolveTotalPages({ + required int total, + required int limit, +}) { + if (total <= 0 || limit <= 0) return 1; + final pages = (total + limit - 1) ~/ limit; + return pages < 1 ? 1 : pages; +} + +class ParsedPagination { + const ParsedPagination({ + required this.page, + required this.limit, + required this.total, + required this.totalPages, + }); + + final int page; + final int limit; + final int total; + final int totalPages; +} + +/// Parse pagination using request fallbacks. [limit] never falls back to item count. +ParsedPagination parsePagination({ + required dynamic body, + required int fallbackPage, + required int fallbackLimit, + required int itemCount, +}) { + final meta = extractPaginationMeta(body); + final page = paginationInt(meta['page']) ?? fallbackPage; + final limit = paginationInt(meta['limit']) ?? + paginationInt(meta['per_page']) ?? + paginationInt(meta['page_size']) ?? + fallbackLimit; + final total = paginationInt(meta['total']) ?? itemCount; + final safeLimit = limit > 0 ? limit : fallbackLimit; + final totalPages = resolveTotalPages(total: total, limit: safeLimit); + + return ParsedPagination( + page: page < 1 ? 1 : page, + limit: safeLimit, + total: total < 0 ? 0 : total, + totalPages: totalPages, + ); +} diff --git a/lib/core/utils/table_search.dart b/lib/core/utils/table_search.dart index ee3ed86..fce1ddf 100644 --- a/lib/core/utils/table_search.dart +++ b/lib/core/utils/table_search.dart @@ -36,6 +36,26 @@ class TableSearch { } return items.where((item) => matches(q, valuesOf(item))).toList(); } + + /// Merges two lists by [idOf], preferring items from [primary] on conflict. + static List mergeById( + Iterable primary, + Iterable secondary, + String Function(T item) idOf, + ) { + final map = {}; + for (final item in secondary) { + final id = idOf(item); + if (id.isEmpty) continue; + map[id] = item; + } + for (final item in primary) { + final id = idOf(item); + if (id.isEmpty) continue; + map[id] = item; + } + return map.values.toList(); + } } /// Debounces search input so API-backed lists are not hit on every keystroke. 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 f866fad..a433f9d 100644 --- a/lib/modules/assets/data/datasources/asset_remote_data_source.dart +++ b/lib/modules/assets/data/datasources/asset_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/pagination_meta.dart'; import '../../../../core/utils/export_file_name.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/asset_model.dart'; @@ -17,7 +18,12 @@ class AssetRemoteDataSource { ApiEndpoints.assets, queryParameters: _queryToMap(query), ); - return _parsePaginated(response.data, AssetModel.fromJson); + return _parsePaginated( + response.data, + AssetModel.fromJson, + fallbackPage: query.page, + fallbackLimit: query.limit, + ); } Future getAssetById(String id) async { @@ -215,7 +221,12 @@ class AssetRemoteDataSource { if (type != null) 'type': type, }, ); - return _parsePaginated(response.data, AssetAlertModel.fromJson); + return _parsePaginated( + response.data, + AssetAlertModel.fromJson, + fallbackPage: page, + fallbackLimit: limit, + ); } Future> getServiceAlerts({String? status}) async { @@ -370,7 +381,12 @@ class AssetRemoteDataSource { if (dueOnly) 'due_only': true, }, ); - return _parsePaginated(response.data, AssetModel.fromJson); + return _parsePaginated( + response.data, + AssetModel.fromJson, + fallbackPage: page, + fallbackLimit: limit, + ); } Future> getMaintenanceLogs(String assetId) async { @@ -464,86 +480,45 @@ class AssetRemoteDataSource { PaginatedResponse _parsePaginated( dynamic body, - T Function(Map) fromJson, - ) { - if (body is! Map) { - return const PaginatedResponse( - items: [], - page: 1, - limit: 20, - total: 0, - totalPages: 1, - ); - } - - final map = Map.from(body); - final raw = map['data']; - final meta = map['meta'] is Map - ? Map.from(map['meta'] as Map) - : {}; - - if (raw is List) { - final items = raw - .whereType() - .map((e) => fromJson(Map.from(e))) - .toList(); - final limit = (meta['limit'] as num?)?.toInt() ?? items.length; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - final explicitTotalPages = - (meta['totalPages'] as num?)?.toInt() ?? - (meta['total_pages'] as num?)?.toInt(); - return PaginatedResponse( - items: items, - page: (meta['page'] as num?)?.toInt() ?? 1, - limit: limit, - total: total, - totalPages: explicitTotalPages ?? - (limit > 0 - ? ((total + limit - 1) ~/ limit).clamp(1, 999999) - : 1), - ); - } - - if (raw is Map) { - final nested = Map.from(raw); - final list = nested['items']; - if (list is List) { - final items = list + T Function(Map) fromJson, { + int fallbackPage = 1, + int fallbackLimit = 20, + }) { + var items = []; + if (body is Map) { + final raw = body['data']; + if (raw is List) { + items = raw .whereType() .map((e) => fromJson(Map.from(e))) .toList(); - final limit = (meta['limit'] as num?)?.toInt() ?? - (nested['limit'] as num?)?.toInt() ?? - 20; - final total = (meta['total'] as num?)?.toInt() ?? - (nested['total'] as num?)?.toInt() ?? - items.length; - final explicitTotalPages = - (meta['totalPages'] as num?)?.toInt() ?? - (meta['total_pages'] as num?)?.toInt() ?? - (nested['totalPages'] as num?)?.toInt() ?? - (nested['total_pages'] as num?)?.toInt(); - return PaginatedResponse( - items: items, - page: (meta['page'] as num?)?.toInt() ?? - (nested['page'] as num?)?.toInt() ?? - 1, - limit: limit, - total: total, - totalPages: explicitTotalPages ?? - (limit > 0 - ? ((total + limit - 1) ~/ limit).clamp(1, 999999) - : 1), - ); + } else if (raw is Map) { + final list = raw['items']; + if (list is List) { + items = list + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + } } } - return const PaginatedResponse( - items: [], - page: 1, - limit: 20, - total: 0, - totalPages: 1, + final pagination = parsePagination( + body: body, + fallbackPage: fallbackPage, + fallbackLimit: fallbackLimit, + itemCount: items.length, + ); + + return PaginatedResponse( + items: items, + page: pagination.page, + limit: fallbackLimit, + total: pagination.total, + totalPages: resolveTotalPages( + total: pagination.total, + limit: fallbackLimit, + ), ); } diff --git a/lib/modules/assets/presentation/providers/assets_provider.dart b/lib/modules/assets/presentation/providers/assets_provider.dart index 420fe15..4f68bfe 100644 --- a/lib/modules/assets/presentation/providers/assets_provider.dart +++ b/lib/modules/assets/presentation/providers/assets_provider.dart @@ -6,6 +6,7 @@ import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../data/repositories/asset_repository_impl.dart'; +import 'asset_categories_provider.dart'; class AssetsListState { const AssetsListState({ @@ -68,11 +69,49 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier { final result = await repository.getAssets(query); if (result.failure != null) throw result.failure!; final page = result.data!; + var items = List.from(page.items); + final search = TableSearch.normalize(query.search); + + // API search often ignores category name — pull matching categories too. + if (search.isNotEmpty && query.itemCategoryId == null) { + final categories = await ref.read(itemCategoriesProvider.future); + var categoryMatches = 0; + for (final category in categories) { + if (!TableSearch.matches(search, [category.name])) continue; + if (++categoryMatches > 5) break; + final categoryId = int.tryParse(category.id); + if (categoryId == null) continue; + final byCategory = await repository.getAssets( + query.copyWith(search: null, itemCategoryId: categoryId), + ); + if (byCategory.failure == null && byCategory.data != null) { + items = TableSearch.mergeById( + items, + byCategory.data!.items, + (asset) => asset.id, + ); + } + } + + items = TableSearch.filter( + items, + search, + (asset) => [ + asset.assetCode, + asset.assetName, + asset.assetCategoryName, + asset.locationName, + asset.status, + asset.condition, + ], + ); + } + return AssetsListState( - assets: page.items, + assets: items, query: query, - total: page.total, - totalPages: page.totalPages, + total: search.isEmpty ? page.total : items.length, + totalPages: search.isEmpty ? page.totalPages : 1, ); } diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index 16593cb..6aeb0c5 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -193,6 +193,7 @@ class _AssetListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemsOnPage: state.assets.length, itemLabel: 'assets', onPageChanged: notifier.setPage, onPageSizeChanged: notifier.setPageSize, diff --git a/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart b/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart index 73c10d1..1acede5 100644 --- a/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_maintenance_screen.dart @@ -142,6 +142,7 @@ class _MyMaintenanceBody extends ConsumerWidget { totalPages: state.totalPages, totalItems: state.total, pageSize: state.limit, + itemsOnPage: state.assets.length, onPageChanged: notifier.setPage, onPageSizeChanged: notifier.setPageSize, itemLabel: 'assets', diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index 81dbd8b..ede560e 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -16,7 +16,6 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; -import '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../providers/asset_form_lookups_provider.dart'; import '../providers/assets_provider.dart'; @@ -1223,7 +1222,6 @@ class _TransferAssetPanelState extends ConsumerState { final _reasonController = TextEditingController(); DateTime _transferDate = DateTime.now(); int? _toLocationId; - int? _toDepartmentId; int? _toUserId; bool _isSubmitting = false; @@ -1249,10 +1247,7 @@ class _TransferAssetPanelState extends ConsumerState { Future _save() async { if (!_formKey.currentState!.validate()) return; - final hasDestination = - _toLocationId != null || - _toDepartmentId != null || - _toUserId != null; + final hasDestination = _toLocationId != null || _toUserId != null; if (!hasDestination) { showSidePanelSnackBar( context, @@ -1266,7 +1261,6 @@ class _TransferAssetPanelState extends ConsumerState { await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({ 'transfer_date': DateFormatter.toApiDate(_transferDate), if (_toLocationId != null) 'to_location_id': _toLocationId, - if (_toDepartmentId != null) 'to_department_id': _toDepartmentId, if (_toUserId != null) 'to_user_id': _toUserId, 'reason': _reasonController.text.trim(), }); @@ -1323,25 +1317,6 @@ class _TransferAssetPanelState extends ConsumerState { onChanged: (v) => setState(() => _toLocationId = v), ), const SizedBox(height: 12), - MasterQuickAddDropdown( - masterId: 'departments', - label: 'To Department', - value: _toDepartmentId, - searchHint: 'Search department...', - options: lookups.departments - .map((option) { - final id = int.tryParse(option.id); - if (id == null) return null; - return AppDropdownOption(value: id, label: option.name); - }) - .whereType>() - .toList(), - refreshLookups: () => - ref.invalidate(assetFormLookupsProvider), - parseCreatedId: int.tryParse, - onChanged: (v) => setState(() => _toDepartmentId = v), - ), - const SizedBox(height: 12), AppSearchableDropdown( label: 'To User', value: _toUserId, @@ -1423,36 +1398,11 @@ class _TransferHistoryEntry extends StatelessWidget { ], ), const SizedBox(height: 16), - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: _TransferLocationBlock( - label: 'From', - location: item.fromLocationName, - department: item.fromDepartmentName, - user: item.fromUserName, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Icon( - Icons.arrow_forward_rounded, - size: 18, - color: theme.colorScheme.outline, - ), - ), - Expanded( - child: _TransferLocationBlock( - label: 'To', - location: item.toLocationName, - department: item.toDepartmentName, - user: item.toUserName, - ), - ), - ], - ), + _TransferFromToGrid( + fromLocation: item.fromLocationName, + fromUser: item.fromUserName, + toLocation: item.toLocationName, + toUser: item.toUserName, ), if (showReason) ...[ const SizedBox(height: 16), @@ -1484,46 +1434,86 @@ class _TransferHistoryEntry extends StatelessWidget { } } -class _TransferLocationBlock extends StatelessWidget { - const _TransferLocationBlock({ - required this.label, - this.location, - this.department, - this.user, +class _TransferFromToGrid extends StatelessWidget { + const _TransferFromToGrid({ + this.fromLocation, + this.fromUser, + this.toLocation, + this.toUser, }); - final String label; - final String? location; - final String? department; - final String? user; + final String? fromLocation; + final String? fromUser; + final String? toLocation; + final String? toUser; + + static const _arrowWidth = 34.0; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final labelStyle = theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: theme.colorScheme.onSurfaceVariant, + ); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: theme.colorScheme.onSurfaceVariant, - ), + Row( + children: [ + Expanded(child: Text('From', style: labelStyle)), + const SizedBox(width: _arrowWidth), + Expanded(child: Text('To', style: labelStyle)), + ], ), const SizedBox(height: 8), - _TransferLocationRow( - icon: Icons.place_outlined, - value: location, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _TransferLocationRow( + icon: Icons.place_outlined, + value: fromLocation, + ), + ), + SizedBox( + width: _arrowWidth, + child: Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + Icons.arrow_forward_rounded, + size: 18, + color: theme.colorScheme.outline, + ), + ), + ), + Expanded( + child: _TransferLocationRow( + icon: Icons.place_outlined, + value: toLocation, + ), + ), + ], ), - _TransferLocationRow( - icon: Icons.apartment_outlined, - value: department, - ), - _TransferLocationRow( - icon: Icons.person_outline, - value: user, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _TransferLocationRow( + icon: Icons.person_outline, + value: fromUser, + ), + ), + const SizedBox(width: _arrowWidth), + Expanded( + child: _TransferLocationRow( + icon: Icons.person_outline, + value: toUser, + ), + ), + ], ), ], ); diff --git a/lib/modules/audit/data/datasources/audit_remote_data_source.dart b/lib/modules/audit/data/datasources/audit_remote_data_source.dart index 9eab1af..b0c6e4f 100644 --- a/lib/modules/audit/data/datasources/audit_remote_data_source.dart +++ b/lib/modules/audit/data/datasources/audit_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/pagination_meta.dart'; import '../../../../shared/models/audit_log_model.dart'; import '../../../../shared/models/export_file_result.dart'; @@ -32,20 +33,23 @@ class AuditRemoteDataSource { : []; final meta = body['meta'] as Map? ?? {}; - final page = (meta['page'] as num?)?.toInt() ?? query.page; - final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - final totalPages = limit > 0 - ? ((total + limit - 1) ~/ limit).clamp(1, 999999) - : 1; + final pagination = parsePagination( + body: body, + fallbackPage: query.page, + fallbackLimit: query.limit, + itemCount: items.length, + ); final filtersRequired = meta['filters_required'] == true; return AuditLogListResult( items: items, - page: page, - limit: limit, - total: total, - totalPages: totalPages, + page: pagination.page, + limit: query.limit, + total: pagination.total, + totalPages: resolveTotalPages( + total: pagination.total, + limit: query.limit, + ), filtersRequired: filtersRequired, ); } diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index 95a3de0..b27b246 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -184,6 +184,7 @@ class _AuditLogsScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemsOnPage: state.items.length, itemLabel: 'audit logs', onPageChanged: notifier.setPage, onPageSizeChanged: notifier.setPageSize, @@ -291,7 +292,10 @@ class _FiltersBar extends StatelessWidget { options: [ const AppDropdownOption(value: null, label: 'All Tables'), ...filters.tableNames.map( - (name) => AppDropdownOption(value: name, label: name), + (name) => AppDropdownOption( + value: name, + label: humanizeLabel(name), + ), ), ], onChanged: onTableChanged, @@ -400,8 +404,9 @@ class _AuditDataTable extends StatelessWidget { AppDataColumn( label: 'Table', flex: 2, - searchText: (row) => row.tableName, - cellBuilder: (_, row) => AppTableCell.text(row.tableName), + searchText: (row) => humanizeLabel(row.tableName), + cellBuilder: (_, row) => + AppTableCell.text(humanizeLabel(row.tableName)), ), AppDataColumn( label: 'Record', @@ -485,7 +490,7 @@ class _AuditCardList extends StatelessWidget { children: [ Expanded( child: Text( - log.tableName, + humanizeLabel(log.tableName), style: Theme.of(context).textTheme.titleMedium, ), ), 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 9f4a60f..d88c85a 100644 --- a/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart +++ b/lib/modules/audit/presentation/widgets/audit_log_detail_panel.dart @@ -57,7 +57,7 @@ class _DetailBody extends StatelessWidget { label: 'Action', child: AppStatusChip(status: detail.action, compact: true), ), - _DetailRow(label: 'Table', value: _humanizeKey(detail.tableName)), + _DetailRow(label: 'Table', value: humanizeLabel(detail.tableName)), _DetailRow(label: 'Record ID', value: detail.recordId ?? '—'), _DetailRow( label: 'Performed At', @@ -269,15 +269,15 @@ List<_FieldRow> _flattenFields( for (final entry in entries) { final key = entry.key.toString(); final label = prefix == null - ? _humanizeKey(key) - : '${_humanizeKey(prefix)} › ${_humanizeKey(key)}'; + ? humanizeLabel(key) + : '${humanizeLabel(prefix)} › ${humanizeLabel(key)}'; final value = entry.value; if (value is Map) { final nested = Map.from(value); final summary = _nestedSummary(nested); if (summary != null) { - rows.add(_FieldRow(label: _humanizeKey(key), value: summary)); + rows.add(_FieldRow(label: humanizeLabel(key), value: summary)); } else { rows.addAll( _flattenFields( @@ -330,7 +330,7 @@ String? _nestedSummary(Map nested) { return nested.entries .map( (e) => - '${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}', + '${humanizeLabel(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}', ) .join(', '); } @@ -393,30 +393,3 @@ String _formatValue(String key, Object? value) { return text; } - -String _humanizeKey(String key) { - final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' '); - if (cleaned.isEmpty) return key; - - const acronyms = { - 'id': 'ID', - 'amc': 'AMC', - 'gst': 'GST', - 'hsn': 'HSN', - 'uom': 'UOM', - 'po': 'PO', - 'grn': 'GRN', - 'url': 'URL', - 'api': 'API', - }; - - return cleaned - .split(RegExp(r'\s+')) - .where((part) => part.isNotEmpty) - .map((part) { - final lower = part.toLowerCase(); - if (acronyms.containsKey(lower)) return acronyms[lower]!; - return '${lower[0].toUpperCase()}${lower.substring(1)}'; - }) - .join(' '); -} diff --git a/lib/modules/grn/data/datasources/grn_remote_data_source.dart b/lib/modules/grn/data/datasources/grn_remote_data_source.dart index 53ec9c6..b6a8807 100644 --- a/lib/modules/grn/data/datasources/grn_remote_data_source.dart +++ b/lib/modules/grn/data/datasources/grn_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/pagination_meta.dart'; import '../../../../core/utils/export_file_name.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/export_file_result.dart'; @@ -16,7 +17,12 @@ class GrnRemoteDataSource { ApiEndpoints.grn, queryParameters: _queryToMap(query), ); - return _parsePaginated(response.data, GrnModel.fromJson); + return _parsePaginated( + response.data, + GrnModel.fromJson, + fallbackPage: query.page, + fallbackLimit: query.limit, + ); } /// Form-dropdown loader: all active GRNs (`dropdown_call=true`). @@ -158,58 +164,45 @@ class GrnRemoteDataSource { PaginatedResponse _parsePaginated( dynamic body, - T Function(Map) fromJson, - ) { - if (body is! Map) { - return const PaginatedResponse( - items: [], - page: 1, - limit: 20, - total: 0, - totalPages: 1, - ); - } - - final raw = body['data']; - final meta = body['meta'] as Map? ?? {}; - - if (raw is List) { - final items = raw.whereType>().map(fromJson).toList(); - final limit = (meta['limit'] as num?)?.toInt() ?? items.length; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - return PaginatedResponse( - items: items, - page: (meta['page'] as num?)?.toInt() ?? 1, - limit: limit, - total: total, - totalPages: - limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, - ); - } - - if (raw is Map) { - final list = raw['items']; - if (list is List) { - final items = list.whereType>().map(fromJson).toList(); - final limit = (meta['limit'] as num?)?.toInt() ?? 20; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - return PaginatedResponse( - items: items, - page: (meta['page'] as num?)?.toInt() ?? 1, - limit: limit, - total: total, - totalPages: - limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, - ); + T Function(Map) fromJson, { + int fallbackPage = 1, + int fallbackLimit = 20, + }) { + var items = []; + if (body is Map) { + final raw = body['data']; + if (raw is List) { + items = raw + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + } else if (raw is Map) { + final list = raw['items']; + if (list is List) { + items = list + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + } } } - return const PaginatedResponse( - items: [], - page: 1, - limit: 20, - total: 0, - totalPages: 1, + final pagination = parsePagination( + body: body, + fallbackPage: fallbackPage, + fallbackLimit: fallbackLimit, + itemCount: items.length, + ); + + return PaginatedResponse( + items: items, + page: pagination.page, + limit: fallbackLimit, + total: pagination.total, + totalPages: resolveTotalPages( + total: pagination.total, + limit: fallbackLimit, + ), ); } } diff --git a/lib/modules/grn/presentation/providers/grn_provider.dart b/lib/modules/grn/presentation/providers/grn_provider.dart index 7a852d7..40e2524 100644 --- a/lib/modules/grn/presentation/providers/grn_provider.dart +++ b/lib/modules/grn/presentation/providers/grn_provider.dart @@ -4,7 +4,11 @@ import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/grn_model.dart'; +import '../../../../shared/models/purchase_order_model.dart'; +import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart'; +import '../../../purchase_orders/presentation/providers/purchase_order_lookups_provider.dart'; import '../../data/repositories/grn_repository_impl.dart'; +import 'grn_lookups_provider.dart'; class GrnListState { const GrnListState({ @@ -67,11 +71,88 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier { final result = await repository.getGrns(query); if (result.failure != null) throw result.failure!; final page = result.data!; + var items = List.from(page.items); + final search = TableSearch.normalize(query.search); + + // API search often ignores PO number / vendor name — enrich via filters. + if (search.isNotEmpty && + query.poId == null && + query.vendorId == null) { + try { + final grnLookups = await ref.read(grnLookupsProvider.future); + final poCandidates = [ + ...grnLookups.receivablePurchaseOrders, + ]; + // Include broader PO dropdown options so search works beyond receivable. + final allPoOptions = await ref + .read(purchaseOrderRepositoryProvider) + .listPurchaseOrderOptions(); + if (allPoOptions.failure == null && allPoOptions.data != null) { + poCandidates.addAll(allPoOptions.data!); + } + + final seenPoIds = {}; + var poMatches = 0; + for (final po in poCandidates) { + if (!seenPoIds.add(po.id)) continue; + if (!TableSearch.matches(search, [po.poNo, po.vendorName])) { + continue; + } + if (++poMatches > 5) break; + final poId = int.tryParse(po.id); + if (poId == null) continue; + final byPo = await repository.getGrns( + query.copyWith(search: null, poId: poId), + ); + if (byPo.failure == null && byPo.data != null) { + items = TableSearch.mergeById( + items, + byPo.data!.items, + (grn) => grn.id, + ); + } + } + + final poLookups = await ref.read(purchaseOrderLookupsProvider.future); + var vendorMatches = 0; + for (final vendor in poLookups.vendors) { + if (!TableSearch.matches(search, [vendor.name])) continue; + if (++vendorMatches > 5) break; + final vendorId = int.tryParse(vendor.id); + if (vendorId == null) continue; + final byVendor = await repository.getGrns( + query.copyWith(search: null, vendorId: vendorId), + ); + if (byVendor.failure == null && byVendor.data != null) { + items = TableSearch.mergeById( + items, + byVendor.data!.items, + (grn) => grn.id, + ); + } + } + } catch (_) { + // Lookups enrichment is best-effort. + } + + items = TableSearch.filter( + items, + search, + (grn) => [ + grn.grnNumber, + grn.poNumber, + grn.vendorName, + grn.locationName, + grn.status, + ], + ); + } + return GrnListState( - grns: page.items, + grns: items, query: query, - total: page.total, - totalPages: page.totalPages, + total: search.isEmpty ? page.total : items.length, + totalPages: search.isEmpty ? page.totalPages : 1, ); } diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index ca46edd..3440936 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -126,6 +126,7 @@ class _GrnListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemsOnPage: state.grns.length, itemLabel: 'GRNs', onPageChanged: ref.read(grnListProvider.notifier).setPage, onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize, 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 b9e0688..a8b86c3 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -332,7 +332,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { const SizedBox(height: 8), FormRow( columnCount: 12, - spans: const [2, 2, 2, 2, 2, 2], + spans: const [2, 2, 2, 3, 3], spacing: 8, stackBelowWidth: 1100, children: [ @@ -398,14 +398,6 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { readOnly: true, fillColor: currentBg, ), - AppTextField( - key: ValueKey('$lineKey-rate'), - controller: item.rateController, - label: 'Rate', - hint: '0.00', - isDense: true, - readOnly: true, - ), AppTextField( key: ValueKey('$lineKey-batch'), controller: item.batchNoController, 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 adf97f5..e11d3e7 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/network/dio_client.dart'; import '../../../../core/utils/active_option.dart'; +import '../../../../core/utils/pagination_meta.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../domain/entities/master_definition.dart'; @@ -17,18 +18,14 @@ class MasterListResult { required this.total, required this.page, required this.limit, + required this.totalPages, }); final List> items; final int total; final int page; final int limit; - - int get totalPages { - if (limit <= 0) return 1; - final pages = (total / limit).ceil(); - return pages < 1 ? 1 : pages; - } + final int totalPages; } class MasterCrudRemoteDataSource { @@ -68,16 +65,20 @@ class MasterCrudRemoteDataSource { .map((item) => Map.from(item)) .toList(); - final meta = raw is Map ? raw : body; - final total = _asInt(meta['total']) ?? items.length; - final currentPage = _asInt(meta['page']) ?? page; - final currentLimit = _asInt(meta['limit']) ?? limit; + final pagination = parsePagination( + body: body, + fallbackPage: page, + fallbackLimit: limit, + itemCount: items.length, + ); return MasterListResult( items: items, - total: total, - page: currentPage, - limit: currentLimit, + total: pagination.total, + page: pagination.page, + // Keep the requested page size so the UI dropdown matches the next request. + limit: limit, + totalPages: resolveTotalPages(total: pagination.total, limit: limit), ); } @@ -196,11 +197,4 @@ class MasterCrudRemoteDataSource { if (data is Map) return Map.from(data); return Map.from(body); } - - int? _asInt(Object? value) { - if (value is int) return value; - if (value is num) return value.toInt(); - if (value is String) return int.tryParse(value); - return null; - } } diff --git a/lib/modules/master_data/data/repositories/master_repository_impl.dart b/lib/modules/master_data/data/repositories/master_repository_impl.dart index d8fbcd9..7b36673 100644 --- a/lib/modules/master_data/data/repositories/master_repository_impl.dart +++ b/lib/modules/master_data/data/repositories/master_repository_impl.dart @@ -21,6 +21,7 @@ class MasterRepositoryImpl implements MasterRepository { int page = 1, int limit = 20, String? search, + Map? extraQueryParameters, }) => safeApiCall( () => remote.list( @@ -28,6 +29,7 @@ class MasterRepositoryImpl implements MasterRepository { page: page, limit: limit, search: search, + extraQueryParameters: extraQueryParameters, ), ); diff --git a/lib/modules/master_data/domain/entities/master_definition.dart b/lib/modules/master_data/domain/entities/master_definition.dart index 5985328..873f730 100644 --- a/lib/modules/master_data/domain/entities/master_definition.dart +++ b/lib/modules/master_data/domain/entities/master_definition.dart @@ -22,6 +22,7 @@ class MasterFieldDef { this.filterByOptionKey, this.visibleWhenFieldKey, this.visibleWhenValue, + this.listNestedKey, }); final String key; @@ -47,6 +48,8 @@ class MasterFieldDef { /// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue]. final String? visibleWhenFieldKey; final String? visibleWhenValue; + /// Nested object key on list rows for display (e.g. `plant` for `parent_id`). + final String? listNestedKey; /// Cache key for dropdown option rows (includes query params when set). String get dropdownLookupKey { @@ -272,12 +275,17 @@ const masterDefinitions = [ label: 'Min Order Qty', type: MasterFieldType.number, required: true, + // Stock items only — hidden when Asset Item is checked. + visibleWhenFieldKey: 'is_asset_item', + visibleWhenValue: 'false', ), MasterFieldDef( key: 'reorder_level', label: 'Reorder Level', type: MasterFieldType.number, required: true, + visibleWhenFieldKey: 'is_asset_item', + visibleWhenValue: 'false', ), MasterFieldDef( key: 'tags', @@ -374,6 +382,7 @@ const masterDefinitions = [ showInList: true, optionsMasterKey: 'locations', optionsQueryParams: const {'type': 'plant'}, + listNestedKey: 'plant', visibleWhenFieldKey: 'type', visibleWhenValue: 'warehouse', required: true, @@ -606,18 +615,25 @@ String masterCellValue(Map row, MasterFieldDef field) { if (explicitName != null && explicitName.toString().trim().isNotEmpty) { return explicitName.toString().trim(); } - final nested = row[baseKey]; - if (nested is Map) { - for (final nestedKey in ['name', 'code', 'description']) { - final nestedValue = nested[nestedKey]; - if (nestedValue != null && - nestedValue.toString().trim().isNotEmpty) { - return nestedValue.toString().trim(); + + final nestedKeys = [ + if (field.listNestedKey != null) field.listNestedKey!, + baseKey, + ]; + for (final nestedKey in nestedKeys) { + final nested = row[nestedKey]; + if (nested is Map) { + for (final nameKey in ['name', 'code', 'description']) { + final nestedValue = nested[nameKey]; + if (nestedValue != null && + nestedValue.toString().trim().isNotEmpty) { + return nestedValue.toString().trim(); + } } + } else if (nested != null && nested.toString().trim().isNotEmpty) { + // Flat denormalized value (e.g. items.hsn_code string) + return nested.toString().trim(); } - } else if (nested != null && nested.toString().trim().isNotEmpty) { - // Flat denormalized value (e.g. items.hsn_code string) - return nested.toString().trim(); } } @@ -670,6 +686,28 @@ String masterFieldDropdownLookupKey({ return '$masterKey?$query'; } +/// Placeholder / hint casing for master field labels (keeps UOM, HSN, GST, etc.). +String masterFieldHintLabel(String label) { + const acronyms = { + 'uom': 'UOM', + 'hsn': 'HSN', + 'gst': 'GST', + 'sac': 'SAC', + 'po': 'PO', + 'grn': 'GRN', + 'amc': 'AMC', + }; + return label + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .map((part) { + final lower = part.toLowerCase(); + return acronyms[lower] ?? lower; + }) + .join(' '); +} + /// Display label for GST filled from HSN nested `gst_rate.description`. String? gstRateDisplayFromValues(Map values) { final nested = values['gst_rate']; diff --git a/lib/modules/master_data/domain/repositories/master_repository.dart b/lib/modules/master_data/domain/repositories/master_repository.dart index f9814c5..88447fd 100644 --- a/lib/modules/master_data/domain/repositories/master_repository.dart +++ b/lib/modules/master_data/domain/repositories/master_repository.dart @@ -9,6 +9,7 @@ abstract class MasterRepository { int page, int limit, String? search, + Map? extraQueryParameters, }); Future>>> listOptions( diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index 96e81b8..003639f 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/utils/pagination_meta.dart'; import '../../../../core/utils/table_search.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../../assets/data/repositories/asset_repository_impl.dart'; @@ -114,45 +115,68 @@ class MasterListNotifier extends FamilyAsyncNotifier { final current = state.valueOrNull; final nextPage = page ?? current?.page ?? 1; final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize; - final nextSearch = search ?? current?.search; + final nextSearch = TableSearch.normalize(search ?? current?.search); + final repository = ref.read(masterRepositoryProvider); - final result = await ref.read(masterRepositoryProvider).list( - _definition, - page: nextPage, - limit: nextLimit, - search: nextSearch, - ); + final result = await repository.list( + _definition, + page: nextPage, + limit: nextLimit, + search: nextSearch.isEmpty ? null : nextSearch, + ); if (result.failure != null) throw result.failure!; - final data = result.data!; + var items = List>.from(result.data!.items); + // API `search` often ignores related/enum fields (e.g. category_type). + // Supplement with dedicated filters and client-side multi-field matching. + if (nextSearch.isNotEmpty) { + if (_definition.id == 'item_categories') { + var typeMatches = 0; + for (final type in categoryTypeOptions) { + if (!TableSearch.matches(nextSearch, [type])) continue; + if (++typeMatches > 5) break; + final typed = await repository.list( + _definition, + page: nextPage, + limit: nextLimit, + extraQueryParameters: {'category_type': type}, + ); + if (typed.failure == null && typed.data != null) { + items = TableSearch.mergeById( + items, + typed.data!.items, + (row) => row['id']?.toString() ?? '', + ); + } + } + } + + items = TableSearch.filter( + items, + nextSearch, + (row) => [ + ..._definition.listFields.map((field) => masterCellValue(row, field)), + masterStatusValue(row), + ], + ); + } + + final data = result.data!; return MasterListState( - items: data.items, - search: nextSearch ?? '', + items: items, + search: nextSearch, page: data.page, // Keep the requested page size so the /page dropdown stays valid // even if API meta omits or mismatches `limit`. limit: nextLimit, - total: data.total, - totalPages: _resolveTotalPages( - apiTotalPages: data.totalPages, - total: data.total, - limit: nextLimit, - ), + total: nextSearch.isEmpty ? data.total : items.length, + totalPages: nextSearch.isEmpty + ? resolveTotalPages(total: data.total, limit: nextLimit) + : resolveTotalPages(total: items.length, limit: nextLimit), ); } - int _resolveTotalPages({ - required int apiTotalPages, - required int total, - required int limit, - }) { - if (apiTotalPages > 0) return apiTotalPages; - if (total <= 0 || limit <= 0) return 1; - final pages = (total / limit).ceil(); - return pages < 1 ? 1 : pages; - } - Future refresh() async { final previous = state.valueOrNull; if (previous == null) { @@ -169,6 +193,13 @@ class MasterListNotifier extends FamilyAsyncNotifier { await _reload(page: 1, search: search); } + /// Clears generic search and reloads the full list. + Future clearSearch() async { + final current = state.valueOrNull; + if (current != null && current.search.isEmpty) return; + await _reload(page: 1, search: ''); + } + Future setPage(int page) async { await _reload(page: page); } @@ -447,10 +478,12 @@ class MasterFormNotifier extends FamilyAsyncNotifier _buildPayload(MasterFormState current) { @@ -569,8 +606,27 @@ class MasterFormNotifier extends FamilyAsyncNotifier? data) { + if (data == null) return null; + for (final key in const ['id', 'ID', 'gst_rate_id']) { + final value = data[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString().trim(); + } + } + final nested = data['data']; + if (nested is Map) { + final value = nested['id']; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString().trim(); + } + } + return null; + } } 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 eb45a6c..e88d7ba 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -37,6 +37,7 @@ class MasterListScreen extends ConsumerStatefulWidget { class _MasterListScreenState extends ConsumerState { final _searchController = TextEditingController(); bool _filtersExpanded = false; + bool _didResetSearchOnOpen = false; MasterDefinition get _definition { final def = masterDefinitionById(widget.masterId); @@ -44,6 +45,24 @@ class _MasterListScreenState extends ConsumerState { return def; } + @override + void initState() { + super.initState(); + // keepAlive can restore a previous search after All Masters → another master. + // Always open list screens with a clean generic search. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _didResetSearchOnOpen) return; + _didResetSearchOnOpen = true; + _searchController.clear(); + final notifier = ref.read(masterListProvider(widget.masterId).notifier); + final existing = + ref.read(masterListProvider(widget.masterId)).valueOrNull; + if (existing != null && existing.search.isNotEmpty) { + notifier.clearSearch(); + } + }); + } + @override void dispose() { _searchController.dispose(); @@ -197,7 +216,13 @@ class _MasterListScreenState extends ConsumerState { ], const SizedBox(width: 8), OutlinedButton.icon( - onPressed: () => context.push(RouteConstants.masterData), + onPressed: () { + _searchController.clear(); + ref + .read(masterListProvider(widget.masterId).notifier) + .clearSearch(); + context.go(RouteConstants.masterData); + }, icon: const Icon(Icons.grid_view_outlined), label: const Text('All Masters'), ), @@ -225,6 +250,7 @@ class _MasterListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.limit, + itemsOnPage: state.items.length, itemLabel: def.title.toLowerCase(), onPageChanged: notifier.setPage, onPageSizeChanged: notifier.setPageSize, 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 5a1ce35..3e5a00c 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -226,7 +226,7 @@ class _MasterFormPanelState extends ConsumerState { if (filterField != null) { for (final f in _definition.formFields) { if (f.key == filterField) { - parentLabel = f.label.toLowerCase(); + parentLabel = masterFieldHintLabel(f.label); break; } } @@ -247,8 +247,8 @@ class _MasterFormPanelState extends ConsumerState { ? 'Select $parentLabel first' : dropdownOptions.isEmpty ? 'No options available' - : 'Select ${field.label.toLowerCase()}', - searchHint: 'Search ${field.label.toLowerCase()}...', + : 'Select ${masterFieldHintLabel(field.label)}', + searchHint: 'Search ${masterFieldHintLabel(field.label)}...', enabled: parentSelected, initialValues: () { final values = {}; @@ -263,11 +263,9 @@ class _MasterFormPanelState extends ConsumerState { } return values.isEmpty ? null : values; }(), - refreshLookups: () { - ref - .read(masterFormProvider(_args).notifier) - .reloadDropdownOptions(); - }, + refreshLookups: () => ref + .read(masterFormProvider(_args).notifier) + .reloadDropdownOptions(), parseCreatedId: (id) => id, onChanged: (selected) => notifier.updateValue(field.key, selected), validator: field.required @@ -287,8 +285,8 @@ class _MasterFormPanelState extends ConsumerState { ? 'Select $parentLabel first' : dropdownOptions.isEmpty ? 'No options available' - : 'Select ${field.label.toLowerCase()}', - searchHint: 'Search ${field.label.toLowerCase()}...', + : 'Select ${masterFieldHintLabel(field.label)}', + searchHint: 'Search ${masterFieldHintLabel(field.label)}...', enabled: field.staticOptions != null ? true : parentSelected && dropdownOptions.isNotEmpty, 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 4bdc92f..98b5788 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 @@ -162,7 +162,7 @@ class _MasterInlineCreateFormState if (filterField != null) { for (final f in _definition.formFields) { if (f.key == filterField) { - parentLabel = f.label.toLowerCase(); + parentLabel = masterFieldHintLabel(f.label); break; } } @@ -181,8 +181,8 @@ class _MasterInlineCreateFormState ? 'Select $parentLabel first' : dropdownOptions.isEmpty ? 'No options available' - : 'Select ${field.label.toLowerCase()}', - searchHint: 'Search ${field.label.toLowerCase()}...', + : 'Select ${masterFieldHintLabel(field.label)}', + searchHint: 'Search ${masterFieldHintLabel(field.label)}...', enabled: field.staticOptions != null ? true : parentSelected && dropdownOptions.isNotEmpty, 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 ad975a9..09675f0 100644 --- a/lib/modules/master_data/presentation/widgets/master_quick_add.dart +++ b/lib/modules/master_data/presentation/widgets/master_quick_add.dart @@ -9,15 +9,16 @@ import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../providers/master_provider.dart'; +import '../../domain/entities/master_definition.dart'; +import 'master_form_panel.dart'; import 'master_inline_create_form.dart'; import '../../../../shared/widgets/app_toast.dart'; -/// Dropdown with inline Quick Add that expands below the field (no dialog). +/// Dropdown with Quick Add. /// -/// When placed inside a [QuickAddInlineHost] (FormRow / SidePanelFormRow), the -/// create form is rendered at full row width under the row so every field and -/// button stays clickable. Without a host, the form expands directly below the -/// dropdown at the available column width. +/// By default expands an inline create form below the field (or under a +/// [QuickAddInlineHost]). Set [openInSidePanel] to open [MasterFormPanel] +/// in a popup side panel instead. class MasterQuickAddDropdown extends ConsumerStatefulWidget { const MasterQuickAddDropdown({ super.key, @@ -35,6 +36,7 @@ class MasterQuickAddDropdown extends ConsumerStatefulWidget { this.initialValues, this.refreshLookups, this.addNewLabel, + this.openInSidePanel = false, }); final String masterId; @@ -54,6 +56,9 @@ class MasterQuickAddDropdown extends ConsumerStatefulWidget { final FutureOr Function()? refreshLookups; final String? addNewLabel; + /// When true, Quick Add opens [MasterFormPanel] in a side panel popup. + final bool openInSidePanel; + @override ConsumerState> createState() => _MasterQuickAddDropdownState(); @@ -75,9 +80,10 @@ class _MasterQuickAddDropdownState bool get _canQuickAdd => ref.can('masters', PermissionAction.create); - void _openInlineForm() { + Future _onAddNew() async { if (!_canQuickAdd) { - showAppToastFromSnackBar(context, + showAppToastFromSnackBar( + context, const SnackBar( content: Text('You do not have permission to add master data'), ), @@ -85,6 +91,41 @@ class _MasterQuickAddDropdownState return; } + if (widget.openInSidePanel) { + await _openSidePanelForm(); + return; + } + + _openInlineForm(); + } + + Future _openSidePanelForm() async { + final sessionId = + 'panel-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}'; + ref.invalidate( + masterFormProvider(( + masterId: widget.masterId, + recordId: null, + initialValues: widget.initialValues, + formSessionId: sessionId, + )), + ); + + final createdId = await showSidePanel( + context, + MasterFormPanel( + masterId: widget.masterId, + initialValues: widget.initialValues, + formSessionId: sessionId, + ), + width: 560, + ); + + if (!mounted || createdId == null) return; + await _applyCreated(createdId); + } + + void _openInlineForm() { final sessionId = 'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}'; ref.invalidate( @@ -119,6 +160,11 @@ class _MasterQuickAddDropdownState } Future _onSaved(String createdId) async { + await _applyCreated(createdId); + _collapse(); + } + + Future _applyCreated(String createdId) async { try { final refresh = widget.refreshLookups; if (refresh != null) await refresh(); @@ -127,15 +173,14 @@ class _MasterQuickAddDropdownState } if (!mounted) return; - if (createdId != 'created') { + // Prefer a real id from create; fall back only when API omits it. + if (createdId.isNotEmpty && createdId != 'created') { final parsed = widget.parseCreatedId(createdId); if (parsed != null) { widget.onChanged(parsed); } } - _collapse(); - if (!mounted) return; showAppToastFromSnackBar( context, @@ -168,7 +213,9 @@ class _MasterQuickAddDropdownState @override Widget build(BuildContext context) { final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId); - _host ??= QuickAddInlineScope.maybeOf(context); + if (!widget.openInSidePanel) { + _host ??= QuickAddInlineScope.maybeOf(context); + } final dropdown = Focus( focusNode: _dropdownFocus, @@ -179,19 +226,19 @@ class _MasterQuickAddDropdownState onChanged: widget.onChanged, validator: widget.validator, hint: widget.hint, - searchHint: - widget.searchHint ?? 'Search ${widget.label.toLowerCase()}...', + searchHint: widget.searchHint ?? + 'Search ${masterFieldHintLabel(widget.label.replaceAll('*', '').trim())}...', enabled: widget.enabled, isDense: widget.isDense, addNewLabel: _canQuickAdd ? addLabel : null, - onAddNew: !_canQuickAdd || !widget.enabled - ? null - : () async => _openInlineForm(), + onAddNew: !_canQuickAdd || !widget.enabled ? null : _onAddNew, ), ); - // Host renders the form at full row width. - if (_host != null || !_expanded || _sessionId == null) { + if (widget.openInSidePanel || + _host != null || + !_expanded || + _sessionId == null) { return dropdown; } 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 cf1d094..376ded7 100644 --- a/lib/modules/masters/data/datasources/master_remote_data_source.dart +++ b/lib/modules/masters/data/datasources/master_remote_data_source.dart @@ -5,6 +5,7 @@ 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 '../../../../core/utils/pagination_meta.dart'; import '../../../../shared/models/user_management_models.dart'; final masterRemoteDataSourceProvider = Provider((ref) { @@ -331,20 +332,12 @@ class MasterRemoteDataSource { } final raw = body['data']; - final meta = body['meta'] is Map - ? Map.from(body['meta'] as Map) - : {}; - List list; - Map pageMeta = meta; - if (raw is List) { list = raw; } else if (raw is Map) { - final map = Map.from(raw); - final items = map['items']; + final items = raw['items']; list = items is List ? items : const []; - pageMeta = {...meta, ...map}; } else { list = const []; } @@ -354,14 +347,20 @@ class MasterRemoteDataSource { .map((item) => Map.from(item)) .toList(); - final total = _asInt(pageMeta['total']) ?? items.length; - final limit = _asInt(pageMeta['limit']) ?? fallbackLimit; - final explicitTotalPages = _asInt(pageMeta['totalPages']) ?? - _asInt(pageMeta['total_pages']); - final totalPages = explicitTotalPages ?? - (limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1); + final pagination = parsePagination( + body: body, + fallbackPage: 1, + fallbackLimit: fallbackLimit, + itemCount: items.length, + ); - return (items: items, totalPages: totalPages < 1 ? 1 : totalPages); + return ( + items: items, + totalPages: resolveTotalPages( + total: pagination.total, + limit: fallbackLimit, + ), + ); } int? _asInt(dynamic value) { diff --git a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart index c9ae1b5..588c16f 100644 --- a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart +++ b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/pagination_meta.dart'; import '../../../../core/utils/export_file_name.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/entity_attachment_model.dart'; @@ -19,7 +20,12 @@ class PurchaseOrderRemoteDataSource { ApiEndpoints.purchaseOrders, queryParameters: _queryToMap(query), ); - return _parsePaginated(response.data, PurchaseOrderModel.fromJson); + return _parsePaginated( + response.data, + PurchaseOrderModel.fromJson, + fallbackPage: query.page, + fallbackLimit: query.limit, + ); } /// Approver-only list of POs with status PENDING_APPROVAL. @@ -30,7 +36,12 @@ class PurchaseOrderRemoteDataSource { ApiEndpoints.purchaseOrdersPendingApproval, queryParameters: _queryToMap(query), ); - return _parsePaginated(response.data, PurchaseOrderModel.fromJson); + return _parsePaginated( + response.data, + PurchaseOrderModel.fromJson, + fallbackPage: query.page, + fallbackLimit: query.limit, + ); } /// Form-dropdown loader (`dropdown_call=true`). Optional status filter. @@ -252,58 +263,45 @@ class PurchaseOrderRemoteDataSource { PaginatedResponse _parsePaginated( dynamic body, - T Function(Map) fromJson, - ) { - if (body is! Map) { - return const PaginatedResponse( - items: [], - page: 1, - limit: 20, - total: 0, - totalPages: 1, - ); - } - - final raw = body['data']; - final meta = body['meta'] as Map? ?? {}; - - if (raw is List) { - final items = raw.whereType>().map(fromJson).toList(); - final limit = (meta['limit'] as num?)?.toInt() ?? items.length; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - return PaginatedResponse( - items: items, - page: (meta['page'] as num?)?.toInt() ?? 1, - limit: limit, - total: total, - totalPages: - limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, - ); - } - - if (raw is Map) { - final list = raw['items']; - if (list is List) { - final items = list.whereType>().map(fromJson).toList(); - final limit = (meta['limit'] as num?)?.toInt() ?? 20; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - return PaginatedResponse( - items: items, - page: (meta['page'] as num?)?.toInt() ?? 1, - limit: limit, - total: total, - totalPages: - limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, - ); + T Function(Map) fromJson, { + int fallbackPage = 1, + int fallbackLimit = 20, + }) { + var items = []; + if (body is Map) { + final raw = body['data']; + if (raw is List) { + items = raw + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + } else if (raw is Map) { + final list = raw['items']; + if (list is List) { + items = list + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + } } } - return const PaginatedResponse( - items: [], - page: 1, - limit: 20, - total: 0, - totalPages: 1, + final pagination = parsePagination( + body: body, + fallbackPage: fallbackPage, + fallbackLimit: fallbackLimit, + itemCount: items.length, + ); + + return PaginatedResponse( + items: items, + page: pagination.page, + limit: fallbackLimit, + total: pagination.total, + totalPages: resolveTotalPages( + total: pagination.total, + limit: fallbackLimit, + ), ); } } diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart index ab75db7..56ce47c 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -1,12 +1,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_handler.dart'; import '../../../../core/utils/table_search.dart'; - +import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; +import 'purchase_order_lookups_provider.dart'; class PurchaseOrdersListState { const PurchaseOrdersListState({ @@ -76,11 +78,18 @@ class PurchaseOrdersListNotifier final result = await repository.getPurchaseOrders(query); if (result.failure != null) throw result.failure!; final page = result.data!; - return PurchaseOrdersListState( - orders: page.items, + final orders = await _enrichPurchaseOrderSearch( + ref: ref, query: query, - total: page.total, - totalPages: page.totalPages, + items: page.items, + load: repository.getPurchaseOrders, + ); + final search = TableSearch.normalize(query.search); + return PurchaseOrdersListState( + orders: orders, + query: query, + total: search.isEmpty ? page.total : orders.length, + totalPages: search.isEmpty ? page.totalPages : 1, ); } @@ -187,11 +196,18 @@ class PendingApprovalPurchaseOrdersListNotifier final result = await repository.getPendingApprovalPurchaseOrders(query); if (result.failure != null) throw result.failure!; final page = result.data!; - return PurchaseOrdersListState( - orders: page.items, + final orders = await _enrichPurchaseOrderSearch( + ref: ref, query: query, - total: page.total, - totalPages: page.totalPages, + items: page.items, + load: repository.getPendingApprovalPurchaseOrders, + ); + final search = TableSearch.normalize(query.search); + return PurchaseOrdersListState( + orders: orders, + query: query, + total: search.isEmpty ? page.total : orders.length, + totalPages: search.isEmpty ? page.totalPages : 1, ); } @@ -425,3 +441,53 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier> _enrichPurchaseOrderSearch({ + required Ref ref, + required PurchaseOrderListQuery query, + required List items, + required Future>> Function( + PurchaseOrderListQuery query, + ) load, +}) async { + final search = TableSearch.normalize(query.search); + if (search.isEmpty || query.vendorId != null) { + return items; + } + + var merged = List.from(items); + try { + final lookups = await ref.read(purchaseOrderLookupsProvider.future); + var vendorMatches = 0; + for (final vendor in lookups.vendors) { + if (!TableSearch.matches(search, [vendor.name])) continue; + if (++vendorMatches > 5) break; + final vendorId = int.tryParse(vendor.id); + if (vendorId == null) continue; + final byVendor = await load( + query.copyWith(search: null, vendorId: vendorId), + ); + if (byVendor.failure == null && byVendor.data != null) { + merged = TableSearch.mergeById( + merged, + byVendor.data!.items, + (order) => order.id, + ); + } + } + } catch (_) { + // Lookups/vendor enrichment is best-effort. + } + + return TableSearch.filter( + merged, + search, + (order) => [ + order.poNo, + order.vendorName, + order.status, + order.billingName, + order.shippingName, + ], + ); +} 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 cdaa5ee..88ec117 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 @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/constants/enums.dart'; import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/network/api_handler.dart'; @@ -9,6 +10,7 @@ import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.dart'; +import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/utils/navigation_utils.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_date_popup.dart'; @@ -21,6 +23,7 @@ import '../../../../shared/widgets/app_side_panel.dart'; import '../../data/repositories/purchase_order_repository_impl.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../../master_data/presentation/widgets/master_quick_add.dart'; +import '../../../vendors/presentation/widgets/vendor_form_panel.dart'; import '../providers/purchase_order_lookups_provider.dart'; import '../providers/purchase_orders_provider.dart'; import '../widgets/po_status_chip.dart'; @@ -417,24 +420,74 @@ class _PurchaseOrderFormScreenState extends ConsumerState setState(() => _vendorId = v), validator: (v) => v == null ? 'Vendor is required' : null, + addNewLabel: ref.can( + 'vendors', + PermissionAction.create, + ) + ? 'Add vendor' + : null, + onAddNew: !ref.can( + 'vendors', + PermissionAction.create, + ) + ? null + : () async { + final createdId = + await openVendorFormPanel( + context, + ref, + ); + if (!mounted || createdId == null) { + return; + } + ref.invalidate( + purchaseOrderLookupsProvider, + ); + await ref.read( + purchaseOrderLookupsProvider.future, + ); + if (!mounted) return; + setState( + () => _vendorId = + int.tryParse(createdId), + ); + }, ), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'locations', label: 'Billing *', value: _dropdownValue(_billingId, locationIds), hint: 'Select billing location', searchHint: 'Search plant or warehouse...', options: _intOptions(lookups.locations), + openInSidePanel: true, + refreshLookups: () async { + ref.invalidate(purchaseOrderLookupsProvider); + await ref.read( + purchaseOrderLookupsProvider.future, + ); + }, + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _billingId = v), validator: (v) => v == null ? 'Billing is required' : null, ), - AppSearchableDropdown( + MasterQuickAddDropdown( + masterId: 'locations', label: 'Shipping *', value: _dropdownValue(_shippingId, locationIds), hint: 'Select shipping location', searchHint: 'Search plant or warehouse...', options: _intOptions(lookups.locations), + openInSidePanel: true, + refreshLookups: () async { + ref.invalidate(purchaseOrderLookupsProvider); + await ref.read( + purchaseOrderLookupsProvider.future, + ); + }, + parseCreatedId: int.tryParse, onChanged: (v) => setState(() => _shippingId = v), validator: (v) => @@ -448,6 +501,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState ref.invalidate(purchaseOrderLookupsProvider), parseCreatedId: int.tryParse, @@ -462,6 +516,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState ref.invalidate(purchaseOrderLookupsProvider), parseCreatedId: int.tryParse, 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 a644706..7a33c99 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 @@ -185,6 +185,7 @@ class _PurchaseOrderListScreenState extends ConsumerState { hint: 'Select item', searchHint: 'Search item name or code...', options: itemOptions, + openInSidePanel: true, refreshLookups: () async { ref.invalidate(purchaseOrderLookupsProvider); await ref.read(purchaseOrderLookupsProvider.future); @@ -570,6 +571,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { hint: 'Select UOM', searchHint: 'Search UOM...', options: uomOptions, + openInSidePanel: true, refreshLookups: () async { ref.invalidate(purchaseOrderLookupsProvider); await ref.read(purchaseOrderLookupsProvider.future); @@ -606,6 +608,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { hint: 'Select', searchHint: 'Search GST %...', options: gstOptions, + openInSidePanel: true, refreshLookups: () async { ref.invalidate(purchaseOrderLookupsProvider); await ref.read(purchaseOrderLookupsProvider.future); 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 25ff44b..110ba1b 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -866,6 +866,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> { totalPages: usersState.totalPages, totalItems: total, pageSize: pageSize, + itemsOnPage: usersState.users.length, itemLabel: 'users', padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), onPageChanged: @@ -1126,29 +1127,39 @@ class _RolesTab extends ConsumerWidget { children: [ Row( children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: appearance.color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - appearance.icon, - color: appearance.color, - size: 20, + Tooltip( + message: role.name, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: + appearance.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + appearance.icon, + color: appearance.color, + size: 20, + ), ), ), const Spacer(), if (canEditRole) IconButton( + tooltip: 'Edit role', icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => onEditRole(role), visualDensity: VisualDensity.compact, ), if (canDeleteRole && !isProtectedRole(role)) IconButton( - icon: const Icon(Icons.delete_outline, size: 18), + tooltip: 'Delete role', + icon: Icon( + Icons.delete_outline, + size: 18, + color: Theme.of(context).colorScheme.error, + ), onPressed: () => onDeleteRole(role), visualDensity: VisualDensity.compact, ), @@ -1183,38 +1194,60 @@ class _RolesTab extends ConsumerWidget { const SizedBox(height: 10), Row( children: [ - Icon( - Icons.people_outline, - size: 14, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '${role.userCount} users', - style: Theme.of(context).textTheme.bodySmall?.copyWith( + Tooltip( + message: 'Assigned users', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.people_outline, + size: 14, color: Theme.of(context) .colorScheme .onSurfaceVariant, ), + const SizedBox(width: 4), + Text( + '${role.userCount} users', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), ), const SizedBox(width: 16), - Icon( - Icons.vpn_key_outlined, - size: 14, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '${role.permissionCount} permissions', - style: Theme.of(context).textTheme.bodySmall?.copyWith( + Tooltip( + message: 'Granted permissions', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.vpn_key_outlined, + size: 14, color: Theme.of(context) .colorScheme .onSurfaceVariant, ), + const SizedBox(width: 4), + Text( + '${role.permissionCount} permissions', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), ), ], ), @@ -1231,6 +1264,7 @@ class _RolesTab extends ConsumerWidget { totalPages: rolesState.totalPages, totalItems: rolesState.total, pageSize: rolesState.limit, + itemsOnPage: rolesState.pagedRoles.length, itemLabel: 'roles', padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), onPageChanged: notifier.setPage, diff --git a/lib/modules/reports/data/datasources/reports_remote_data_source.dart b/lib/modules/reports/data/datasources/reports_remote_data_source.dart index 1f05c5d..fdd97ad 100644 --- a/lib/modules/reports/data/datasources/reports_remote_data_source.dart +++ b/lib/modules/reports/data/datasources/reports_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/pagination_meta.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../shared/models/export_file_result.dart'; import '../../domain/entities/depreciation_report.dart'; @@ -47,12 +48,16 @@ class ReportsRemoteDataSource { ) .toList(); - final page = (meta['page'] as num?)?.toInt() ?? query.page; - final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - final totalPages = limit > 0 - ? ((total + limit - 1) ~/ limit).clamp(1, 999999) - : 1; + final pagination = parsePagination( + body: body, + fallbackPage: query.page, + fallbackLimit: query.limit, + itemCount: items.length, + ); + final page = pagination.page; + final limit = query.limit; + final total = pagination.total; + final totalPages = resolveTotalPages(total: total, limit: limit); final summaryRaw = meta['summary']; final summary = summaryRaw is Map diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index a59fc40..947d835 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -208,6 +208,7 @@ class _DepreciationReportScreenState totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemsOnPage: state.items.length, itemLabel: 'assets', onPageChanged: notifier.setPage, onPageSizeChanged: notifier.setPageSize, diff --git a/lib/modules/roles/presentation/providers/roles_provider.dart b/lib/modules/roles/presentation/providers/roles_provider.dart index 7518c7c..89351d7 100644 --- a/lib/modules/roles/presentation/providers/roles_provider.dart +++ b/lib/modules/roles/presentation/providers/roles_provider.dart @@ -162,6 +162,7 @@ class PermissionMatrixNotifier final current = state.valueOrNull; if (current == null) return; + final normalizedAction = action.toLowerCase(); final updated = current.modules.map((row) { if (row.moduleId != moduleId) return row; if (!isPermissionActionApplicable( @@ -172,7 +173,19 @@ class PermissionMatrixNotifier return row; } final granted = Map.from(row.granted); - granted[action] = value; + granted[normalizedAction] = value; + + // CREATE / EDIT imply VIEW. + if (value && + (normalizedAction == 'create' || normalizedAction == 'edit') && + isPermissionActionApplicable( + row.code, + 'view', + columnActions: current.actionColumns, + )) { + granted['view'] = true; + } + return row.copyWith(granted: granted); }).toList(); diff --git a/lib/modules/roles/presentation/screens/role_list_screen.dart b/lib/modules/roles/presentation/screens/role_list_screen.dart index 608d0a7..f89f1fd 100644 --- a/lib/modules/roles/presentation/screens/role_list_screen.dart +++ b/lib/modules/roles/presentation/screens/role_list_screen.dart @@ -81,6 +81,7 @@ class _RoleListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.limit, + itemsOnPage: state.roles.length, itemLabel: 'roles', onPageChanged: notifier.setPage, onPageSizeChanged: notifier.setPageSize, diff --git a/lib/modules/settings/presentation/screens/appearance_settings_screen.dart b/lib/modules/settings/presentation/screens/appearance_settings_screen.dart index f424e1a..0631129 100644 --- a/lib/modules/settings/presentation/screens/appearance_settings_screen.dart +++ b/lib/modules/settings/presentation/screens/appearance_settings_screen.dart @@ -9,6 +9,75 @@ import '../providers/settings_provider.dart'; import '../widgets/settings_widgets.dart'; import '../../../../shared/widgets/app_toast.dart'; +class _BrandThemePreset { + const _BrandThemePreset({ + required this.name, + required this.primary, + required this.secondary, + }); + + final String name; + final int primary; + final int secondary; + + bool matches(BrandingConfig branding) => + branding.primaryColorValue == primary && + branding.secondaryColorValue == secondary; +} + +const _brandThemePresets = <_BrandThemePreset>[ + _BrandThemePreset( + name: 'Ocean Blue', + primary: 0xFF2563EB, + secondary: 0xFF0891B2, + ), + _BrandThemePreset( + name: 'Royal Purple', + primary: 0xFF6D28D9, + secondary: 0xFF0F766E, + ), + _BrandThemePreset( + name: 'Emerald Green', + primary: 0xFF15803D, + secondary: 0xFF0F766E, + ), + _BrandThemePreset( + name: 'Sunset Orange', + primary: 0xFFEA580C, + secondary: 0xFFD97706, + ), + _BrandThemePreset( + name: 'Crimson Red', + primary: 0xFFDC2626, + secondary: 0xFFB45309, + ), + _BrandThemePreset( + name: 'Indigo Sky', + primary: 0xFF4F46E5, + secondary: 0xFF0284C7, + ), + _BrandThemePreset( + name: 'Slate Blue', + primary: 0xFF334155, + secondary: 0xFF2563EB, + ), + _BrandThemePreset( + name: 'Teal Mint', + primary: 0xFF0F766E, + secondary: 0xFF059669, + ), + _BrandThemePreset( + name: 'Rose Pink', + primary: 0xFFDB2777, + secondary: 0xFF7C3AED, + ), + _BrandThemePreset( + name: 'Charcoal Gold', + primary: 0xFF374151, + secondary: 0xFFD97706, + ), +]; + class AppearanceSettingsScreen extends ConsumerWidget { const AppearanceSettingsScreen({super.key}); @@ -17,6 +86,9 @@ class AppearanceSettingsScreen extends ConsumerWidget { final themeMode = ref.watch(themeModeProvider); final branding = ref.watch(brandingProvider); final uiPrefs = ref.watch(appSettingsProvider).uiPreferences; + final selectedPreset = _brandThemePresets + .where((preset) => preset.matches(branding)) + .firstOrNull; return SettingsPageLayout( title: 'Appearance', @@ -43,58 +115,66 @@ class AppearanceSettingsScreen extends ConsumerWidget { const SizedBox(height: 16), SettingsFormCard( title: 'Branding', - subtitle: 'Primary and secondary colors for the application', + subtitle: + 'Professionally curated primary and secondary color pairs', children: [ - ListTile( - contentPadding: EdgeInsets.zero, - leading: CircleAvatar(backgroundColor: branding.primaryColor), - title: const Text('Primary Color'), - subtitle: Text( - '#${branding.primaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}', - ), + _BrandingPreviewCard( + primary: branding.primaryColor, + secondary: branding.secondaryColor, + themeName: selectedPreset?.name ?? 'Custom', + primaryHex: _hex(branding.primaryColorValue), + secondaryHex: _hex(branding.secondaryColorValue), ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: CircleAvatar(backgroundColor: branding.secondaryColor), - title: const Text('Secondary Color'), - subtitle: Text( - '#${branding.secondaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}', - ), + const SizedBox(height: 20), + Text( + 'Color themes', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _ColorPreset( - label: 'Blue', - primary: 0xFF1565C0, - secondary: 0xFF00897B, - branding: branding, - ref: ref, - ), - _ColorPreset( - label: 'Purple', - primary: 0xFF6A1B9A, - secondary: 0xFF00838F, - branding: branding, - ref: ref, - ), - _ColorPreset( - label: 'Green', - primary: 0xFF2E7D32, - secondary: 0xFF558B2F, - branding: branding, - ref: ref, - ), - _ColorPreset( - label: 'Orange', - primary: 0xFFE65100, - secondary: 0xFFF57C00, - branding: branding, - ref: ref, - ), - ], + const SizedBox(height: 4), + Text( + 'Choose a cohesive pair optimized for light and dark modes.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final crossAxisCount = width >= 720 + ? 3 + : width >= 480 + ? 2 + : 1; + final spacing = 12.0; + final itemWidth = + (width - spacing * (crossAxisCount - 1)) / crossAxisCount; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final preset in _brandThemePresets) + SizedBox( + width: itemWidth, + child: _ThemePresetCard( + preset: preset, + selected: preset.matches(branding), + onTap: () { + ref.read(brandingProvider.notifier).updateBranding( + branding.copyWith( + primaryColorValue: preset.primary, + secondaryColorValue: preset.secondary, + ), + ); + }, + ), + ), + ], + ); + }, ), ], ), @@ -112,10 +192,13 @@ class AppearanceSettingsScreen extends ConsumerWidget { : 'Horizontal menu bar at the top', ), value: layout, - groupValue: NavigationLayout.fromValue(uiPrefs.navigationLayout), + groupValue: + NavigationLayout.fromValue(uiPrefs.navigationLayout), onChanged: (v) { if (v != null) { - ref.read(appSettingsProvider.notifier).updateUiPreferences( + ref + .read(appSettingsProvider.notifier) + .updateUiPreferences( uiPrefs.copyWith(navigationLayout: v.value), ); } @@ -183,8 +266,11 @@ class AppearanceSettingsScreen extends ConsumerWidget { AppButton( label: 'Settings Auto-Saved', onPressed: () { - showAppToastFromSnackBar(context, - const SnackBar(content: Text('Appearance settings are saved automatically')), + showAppToastFromSnackBar( + context, + const SnackBar( + content: Text('Appearance settings are saved automatically'), + ), ); }, ), @@ -198,42 +284,348 @@ class AppearanceSettingsScreen extends ConsumerWidget { ThemeModeOption.dark => 'Dark Theme', ThemeModeOption.system => 'System Theme', }; + + static String _hex(int value) => + '#${value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}'; } -class _ColorPreset extends StatelessWidget { - const _ColorPreset({ - required this.label, +class _BrandingPreviewCard extends StatelessWidget { + const _BrandingPreviewCard({ required this.primary, required this.secondary, - required this.branding, - required this.ref, + required this.themeName, + required this.primaryHex, + required this.secondaryHex, }); - final String label; - final int primary; - final int secondary; - final BrandingConfig branding; - final WidgetRef ref; + final Color primary; + final Color secondary; + final String themeName; + final String primaryHex; + final String secondaryHex; @override Widget build(BuildContext context) { - return OutlinedButton( - onPressed: () { - ref.read(brandingProvider.notifier).updateBranding( - branding.copyWith( - primaryColorValue: primary, - secondaryColorValue: secondary, - ), - ); - }, - child: Row( - mainAxisSize: MainAxisSize.min, + final theme = Theme.of(context); + final onPrimary = + ThemeData.estimateBrightnessForColor(primary) == Brightness.dark + ? Colors.white + : Colors.black87; + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + ), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - CircleAvatar(radius: 8, backgroundColor: Color(primary)), - const SizedBox(width: 6), - CircleAvatar(radius: 8, backgroundColor: Color(secondary)), - const SizedBox(width: 8), - Text(label), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: primary, + child: Row( + children: [ + Icon(Icons.dashboard_outlined, color: onPrimary, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Bharat ERP · $themeName', + style: theme.textTheme.labelLarge?.copyWith( + color: onPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: secondary, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Accent', + style: theme.textTheme.labelSmall?.copyWith( + color: ThemeData.estimateBrightnessForColor(secondary) == + Brightness.dark + ? Colors.white + : Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Live preview', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'How headers, primary actions, and secondary accents will look.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: () {}, + style: FilledButton.styleFrom( + backgroundColor: primary, + foregroundColor: onPrimary, + ), + icon: const Icon(Icons.add, size: 18), + label: const Text('Add'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: secondary, + side: BorderSide( + color: secondary.withValues(alpha: 0.55), + ), + ), + child: const Text('Export'), + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + _ColorSwatchLabel( + color: primary, + label: 'Primary', + hex: primaryHex, + ), + const SizedBox(width: 16), + _ColorSwatchLabel( + color: secondary, + label: 'Secondary', + hex: secondaryHex, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ColorSwatchLabel extends StatelessWidget { + const _ColorSwatchLabel({ + required this.color, + required this.label, + required this.hex, + }); + + final Color color; + final String label; + final String hex; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.25), + ), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.28), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + hex, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ); + } +} + +class _ThemePresetCard extends StatelessWidget { + const _ThemePresetCard({ + required this.preset, + required this.selected, + required this.onTap, + }); + + final _BrandThemePreset preset; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primary = Color(preset.primary); + final secondary = Color(preset.secondary); + final borderColor = selected + ? primary + : theme.colorScheme.outline.withValues(alpha: 0.28); + + return Material( + color: selected + ? primary.withValues(alpha: 0.06) + : theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: borderColor, + width: selected ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _StackedColorCircles( + primary: primary, + secondary: secondary, + ), + const Spacer(), + if (selected) + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + size: 14, + color: ThemeData.estimateBrightnessForColor(primary) == + Brightness.dark + ? Colors.white + : Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + preset.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '${AppearanceSettingsScreen._hex(preset.primary)} · ' + '${AppearanceSettingsScreen._hex(preset.secondary)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StackedColorCircles extends StatelessWidget { + const _StackedColorCircles({ + required this.primary, + required this.secondary, + }); + + final Color primary; + final Color secondary; + + @override + Widget build(BuildContext context) { + final outline = Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2); + + return SizedBox( + width: 56, + height: 32, + child: Stack( + children: [ + Positioned( + left: 0, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: primary, + shape: BoxShape.circle, + border: Border.all(color: outline, width: 2), + ), + ), + ), + Positioned( + left: 22, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: secondary, + shape: BoxShape.circle, + border: Border.all(color: outline, width: 2), + ), + ), + ), ], ), ); 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 74693f1..baceb5e 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/pagination_meta.dart'; import '../../../../core/utils/active_option.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/export_file_result.dart'; @@ -80,19 +81,22 @@ class UserRemoteDataSource { (json) => ManagedUserModel.fromJson(json! as Map), ).items; - final meta = body['meta'] as Map? ?? {}; - final page = (meta['page'] as num?)?.toInt() ?? query.page; - final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - final totalPages = - limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1; + final pagination = parsePagination( + body: body, + fallbackPage: query.page, + fallbackLimit: query.limit, + itemCount: items.length, + ); return PaginatedResponse( items: items, - page: page, - limit: limit, - total: total, - totalPages: totalPages, + page: pagination.page, + limit: query.limit, + total: pagination.total, + totalPages: resolveTotalPages( + total: pagination.total, + limit: query.limit, + ), ); } diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index 0764ce9..4342ced 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -101,6 +101,7 @@ class _UserListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemsOnPage: state.users.length, itemLabel: 'users', onPageChanged: ref.read(usersListProvider.notifier).setPage, onPageSizeChanged: diff --git a/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart b/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart index 1d98ca8..569e86f 100644 --- a/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart +++ b/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/pagination_meta.dart'; import '../../../../core/utils/export_file_name.dart'; import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/export_file_result.dart'; @@ -17,7 +18,12 @@ class VendorRemoteDataSource { ApiEndpoints.vendors, queryParameters: _queryToMap(query), ); - return _parsePaginated(response.data, VendorModel.fromJson); + return _parsePaginated( + response.data, + VendorModel.fromJson, + fallbackPage: query.page, + fallbackLimit: query.limit, + ); } /// Form-dropdown loader: all active vendors (`dropdown_call=true`). @@ -246,47 +252,45 @@ class VendorRemoteDataSource { PaginatedResponse _parsePaginated( dynamic body, - T Function(Map) fromJson, - ) { - if (body is! Map) { - return const PaginatedResponse( - items: [], - page: 1, - limit: 20, - total: 0, - totalPages: 1, - ); + T Function(Map) fromJson, { + int fallbackPage = 1, + int fallbackLimit = 20, + }) { + var items = []; + if (body is Map) { + final raw = body['data']; + if (raw is List) { + items = raw + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + } else if (raw is Map) { + final list = raw['items']; + if (list is List) { + items = list + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + } + } } - final raw = body['data']; - final meta = body['meta'] as Map? ?? {}; + final pagination = parsePagination( + body: body, + fallbackPage: fallbackPage, + fallbackLimit: fallbackLimit, + itemCount: items.length, + ); - if (raw is List) { - final items = raw.whereType>().map(fromJson).toList(); - final limit = (meta['limit'] as num?)?.toInt() ?? items.length; - final total = (meta['total'] as num?)?.toInt() ?? items.length; - return PaginatedResponse( - items: items, - page: (meta['page'] as num?)?.toInt() ?? 1, - limit: limit, - total: total, - totalPages: limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, - ); - } - - if (raw is Map) { - return PaginatedResponse.fromJson( - raw, - (json) => fromJson(json! as Map), - ); - } - - return const PaginatedResponse( - items: [], - page: 1, - limit: 20, - total: 0, - totalPages: 1, + return PaginatedResponse( + items: items, + page: pagination.page, + limit: fallbackLimit, + total: pagination.total, + totalPages: resolveTotalPages( + total: pagination.total, + limit: fallbackLimit, + ), ); } } diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index 426bcfe..e66dde4 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -129,6 +129,7 @@ class _VendorListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemsOnPage: state.vendors.length, itemLabel: 'vendors', onPageChanged: ref.read(vendorsListProvider.notifier).setPage, onPageSizeChanged: diff --git a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart index 364e1d3..886fdba 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart @@ -19,19 +19,20 @@ import '../../data/repositories/vendor_repository_impl.dart'; import '../providers/vendors_provider.dart'; import '../../../../shared/widgets/app_toast.dart'; -Future openVendorFormPanel( +Future openVendorFormPanel( BuildContext context, WidgetRef ref, { String? vendorId, }) async { ref.invalidate(vendorFormProvider(vendorId)); - final saved = await showSidePanel( + final savedId = await showSidePanel( context, VendorFormPanel(vendorId: vendorId), width: 560, ); - if (saved == true && context.mounted) { - showAppToastFromSnackBar(context, + if (savedId != null && context.mounted) { + showAppToastFromSnackBar( + context, SnackBar( content: Text( vendorId == null @@ -41,6 +42,7 @@ Future openVendorFormPanel( ), ); } + return savedId; } class VendorFormPanel extends ConsumerStatefulWidget { @@ -148,12 +150,15 @@ class _VendorFormPanelState extends ConsumerState { try { final notifier = ref.read(vendorFormProvider(widget.vendorId).notifier); final payload = _buildPayload(); + final String savedId; if (widget.isEditing) { await notifier.submitUpdate(widget.vendorId!, payload); + savedId = widget.vendorId!; } else { - await notifier.submitCreate(payload); + final created = await notifier.submitCreate(payload); + savedId = created.id; } - if (mounted) Navigator.of(context, rootNavigator: true).pop(true); + if (mounted) Navigator.of(context, rootNavigator: true).pop(savedId); } catch (e) { if (mounted) { showAppToastFromSnackBar(context, diff --git a/lib/shared/widgets/app_dropdown.dart b/lib/shared/widgets/app_dropdown.dart index 44d15bd..5a58021 100644 --- a/lib/shared/widgets/app_dropdown.dart +++ b/lib/shared/widgets/app_dropdown.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/utils/formatters.dart'; import 'app_searchable_dropdown.dart'; class AppDropdownOption { @@ -61,14 +62,15 @@ class AppDropdown extends StatelessWidget { @override Widget build(BuildContext context) { + final subject = dropdownHintLabel(label); return AppSearchableDropdown( label: label, value: value, options: options, onChanged: onChanged, validator: validator, - hint: hint, - searchHint: searchHint ?? 'Search ${label.toLowerCase()}...', + hint: hint ?? 'Select $subject', + searchHint: searchHint ?? 'Search $subject...', enabled: enabled, isDense: isDense, addNewLabel: addNewLabel, diff --git a/lib/shared/widgets/app_pagination.dart b/lib/shared/widgets/app_pagination.dart index 77f0642..0181873 100644 --- a/lib/shared/widgets/app_pagination.dart +++ b/lib/shared/widgets/app_pagination.dart @@ -9,6 +9,7 @@ class AppPagination extends StatelessWidget { required this.pageSize, required this.onPageChanged, this.onPageSizeChanged, + this.itemsOnPage, this.pageSizeOptions = const [10, 20, 50], this.itemLabel = 'items', this.maxVisiblePages = 10, @@ -21,6 +22,8 @@ class AppPagination extends StatelessWidget { final int pageSize; final ValueChanged onPageChanged; final ValueChanged? onPageSizeChanged; + /// When set, "Showing" end index uses loaded row count instead of pageSize. + final int? itemsOnPage; final List pageSizeOptions; final String itemLabel; final int maxVisiblePages; @@ -30,7 +33,11 @@ class AppPagination extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final start = totalItems == 0 ? 0 : ((currentPage - 1) * pageSize) + 1; - final end = (currentPage * pageSize).clamp(0, totalItems); + final end = totalItems == 0 + ? 0 + : itemsOnPage != null + ? (start + itemsOnPage! - 1).clamp(0, totalItems) + : (currentPage * pageSize).clamp(0, totalItems); final visiblePages = _visiblePageNumbers(currentPage, totalPages); final sizes = List.from(pageSizeOptions); if (!sizes.contains(pageSize)) { diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index db4f9bb..c55beb6 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/utils/formatters.dart'; import 'app_dropdown.dart'; /// Dropdown that opens a searchable popup anchored to the field. @@ -206,7 +207,8 @@ class _AppSearchableDropdownState extends State> { initialValue: widget.value, validator: widget.validator, builder: (field) { - final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}'; + final effectiveHint = + widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}'; final canOpen = widget.enabled && (widget.options.isNotEmpty || widget.onAddNew != null); final colors = theme.colorScheme; @@ -595,7 +597,7 @@ class _AppSearchableLookupFieldState Widget build(BuildContext context) { final theme = Theme.of(context); final effectiveHint = - widget.hint ?? 'Select ${widget.label.toLowerCase()}'; + widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}'; final canOpen = widget.enabled && (widget.options.isNotEmpty || widget.onAddNew != null); final colors = theme.colorScheme; diff --git a/lib/shared/widgets/app_searchable_multi_select_dropdown.dart b/lib/shared/widgets/app_searchable_multi_select_dropdown.dart index b78b78e..657bbe3 100644 --- a/lib/shared/widgets/app_searchable_multi_select_dropdown.dart +++ b/lib/shared/widgets/app_searchable_multi_select_dropdown.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/utils/formatters.dart'; import 'app_dropdown.dart'; /// Dropdown that opens a searchable popup with multi-select checkboxes. @@ -167,7 +168,7 @@ class _AppSearchableMultiSelectDropdownState validator: widget.validator, builder: (field) { final effectiveHint = - widget.hint ?? 'Select ${widget.label.toLowerCase()}'; + widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}'; final canOpen = widget.enabled && widget.options.isNotEmpty; final colors = theme.colorScheme; final labels = _selectedLabels(); diff --git a/lib/shared/widgets/app_top_nav.dart b/lib/shared/widgets/app_top_nav.dart index ec4abc2..1a79ea5 100644 --- a/lib/shared/widgets/app_top_nav.dart +++ b/lib/shared/widgets/app_top_nav.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -28,9 +30,33 @@ class AppTopNav extends ConsumerWidget { final void Function(String route) onItemTap; final UserModel? user; - bool _isSelected(String route) { + bool _routeMatches(String route) { if (route == '/') return currentRoute == route; - return currentRoute.startsWith(route); + if (currentRoute == route) return true; + return currentRoute.startsWith('$route/'); + } + + bool _isSelectedAmongSiblings( + String route, + List siblings, + ) { + if (!_routeMatches(route)) return false; + for (final sibling in siblings) { + if (sibling.route == route) continue; + if (sibling.route.length > route.length && _routeMatches(sibling.route)) { + return false; + } + } + return true; + } + + bool _isGroupActive(menu.MenuItem item) => item.children.any( + (child) => _isSelectedAmongSiblings(child.route, item.children), + ); + + bool _isItemSelected(menu.MenuItem item) { + if (item.children.isNotEmpty) return _isGroupActive(item); + return _routeMatches(item.route); } @override @@ -72,30 +98,25 @@ class AppTopNav extends ConsumerWidget { scrollDirection: Axis.horizontal, child: Row( children: menuItems.map((item) { - final selected = _isSelected(item.route); + final selected = _isItemSelected(item); return Padding( padding: const EdgeInsets.only(right: 4), - child: TextButton( - onPressed: () => onItemTap(item.route), - style: TextButton.styleFrom( - foregroundColor: selected - ? theme.colorScheme.primary - : theme.colorScheme.onSurfaceVariant, - backgroundColor: selected - ? theme.colorScheme.primary.withValues(alpha: 0.08) - : Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: Text( - item.label, - style: TextStyle( - fontWeight: - selected ? FontWeight.w600 : FontWeight.w500, - ), - ), - ), + child: item.children.isNotEmpty + ? _TopNavMenuGroup( + item: item, + selected: selected, + isChildSelected: (route) => + _isSelectedAmongSiblings( + route, + item.children, + ), + onChildTap: onItemTap, + ) + : _TopNavMenuButton( + label: item.label, + selected: selected, + onPressed: () => onItemTap(item.route), + ), ); }).toList(), ), @@ -123,6 +144,294 @@ class AppTopNav extends ConsumerWidget { } } +class _TopNavMenuLabel extends StatelessWidget { + const _TopNavMenuLabel({ + required this.label, + required this.selected, + this.showChevron = false, + }); + + final String label; + final bool selected; + final bool showChevron; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: selected + ? theme.colorScheme.primary.withValues(alpha: 0.08) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: color, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + if (showChevron) ...[ + const SizedBox(width: 2), + Icon(Icons.arrow_drop_down, size: 18, color: color), + ], + ], + ), + ); + } +} + +class _TopNavMenuButton extends StatelessWidget { + const _TopNavMenuButton({ + required this.label, + required this.selected, + required this.onPressed, + }); + + final String label; + final bool selected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: _TopNavMenuLabel(label: label, selected: selected), + ); + } +} + +class _TopNavMenuGroup extends StatefulWidget { + const _TopNavMenuGroup({ + required this.item, + required this.selected, + required this.isChildSelected, + required this.onChildTap, + }); + + final menu.MenuItem item; + final bool selected; + final bool Function(String route) isChildSelected; + final void Function(String route) onChildTap; + + @override + State<_TopNavMenuGroup> createState() => _TopNavMenuGroupState(); +} + +class _TopNavMenuGroupState extends State<_TopNavMenuGroup> { + final _anchorKey = GlobalKey(); + OverlayEntry? _overlayEntry; + Timer? _hideTimer; + bool _open = false; + + @override + void dispose() { + _hideTimer?.cancel(); + _removeOverlay(); + super.dispose(); + } + + void _cancelHide() { + _hideTimer?.cancel(); + _hideTimer = null; + } + + void _scheduleHide() { + _cancelHide(); + _hideTimer = Timer(const Duration(milliseconds: 120), () { + if (!mounted) return; + _removeOverlay(); + }); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + if (mounted && _open) setState(() => _open = false); + } + + void _showOverlay() { + _cancelHide(); + if (_overlayEntry != null) return; + _insertOverlay(); + if (_overlayEntry == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _overlayEntry != null) return; + _insertOverlay(); + }); + } + } + + void _insertOverlay() { + if (_overlayEntry != null) return; + + final renderBox = + _anchorKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize) return; + + final anchorTopLeft = renderBox.localToGlobal(Offset.zero); + final anchorSize = renderBox.size; + const panelWidth = 220.0; + + _overlayEntry = OverlayEntry( + builder: (overlayContext) { + final theme = Theme.of(overlayContext); + final primary = theme.colorScheme.primary; + final isDark = theme.brightness == Brightness.dark; + final panelColor = isDark ? theme.colorScheme.surface : Colors.white; + + return Positioned( + left: anchorTopLeft.dx, + top: anchorTopLeft.dy, + child: TapRegion( + onTapOutside: (_) => _removeOverlay(), + child: MouseRegion( + onEnter: (_) => _cancelHide(), + onExit: (_) => _scheduleHide(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: anchorSize.width, + height: anchorSize.height, + ), + Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + color: panelColor, + shadowColor: Colors.black26, + child: SizedBox( + width: panelWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child in widget.item.children) + _TopNavSubmenuItem( + icon: child.icon, + label: child.label, + selected: widget.isChildSelected(child.route), + primary: primary, + onTap: () { + _removeOverlay(); + widget.onChildTap(child.route); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + + Overlay.of(context).insert(_overlayEntry!); + setState(() => _open = true); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => _showOverlay(), + onExit: (_) => _scheduleHide(), + child: GestureDetector( + onTap: _showOverlay, + child: KeyedSubtree( + key: _anchorKey, + child: _TopNavMenuLabel( + label: widget.item.label, + selected: widget.selected || _open, + showChevron: true, + ), + ), + ), + ); + } +} + +class _TopNavSubmenuItem extends StatefulWidget { + const _TopNavSubmenuItem({ + required this.icon, + required this.label, + required this.selected, + required this.primary, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool selected; + final Color primary; + final VoidCallback onTap; + + @override + State<_TopNavSubmenuItem> createState() => _TopNavSubmenuItemState(); +} + +class _TopNavSubmenuItemState extends State<_TopNavSubmenuItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final highlight = widget.selected || _hovered; + final color = highlight ? widget.primary : theme.colorScheme.onSurfaceVariant; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + height: 40, + margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: highlight + ? widget.primary.withValues(alpha: 0.08) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(widget.icon, size: 18, color: color), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: color, + fontWeight: + highlight ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + class _TopNavUserMenu extends ConsumerWidget { const _TopNavUserMenu({this.userName, this.avatarUrl}); diff --git a/lib/shared/widgets/theme_keyed_subtree.dart b/lib/shared/widgets/theme_keyed_subtree.dart index 738a08b..5120710 100644 --- a/lib/shared/widgets/theme_keyed_subtree.dart +++ b/lib/shared/widgets/theme_keyed_subtree.dart @@ -17,10 +17,13 @@ class ThemeKeyedSubtree extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(themeModeProvider); + final branding = ref.watch(brandingProvider); final brightness = Theme.of(context).brightness; + final brandKey = + '${branding.primaryColorValue}-${branding.secondaryColorValue}'; final key = pageKey == null - ? '$themeMode-$brightness' - : '$pageKey-$themeMode-$brightness'; + ? '$themeMode-$brightness-$brandKey' + : '$pageKey-$themeMode-$brightness-$brandKey'; return KeyedSubtree( key: ValueKey(key),