From b3a03668482f5ca520bd4be2ac5dad9935a43551 Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Thu, 23 Jul 2026 17:59:45 +0530 Subject: [PATCH] review changes fix --- lib/core/utils/formatters.dart | 101 ++- lib/core/utils/validators.dart | 36 +- .../providers/asset_categories_provider.dart | 15 +- .../asset_form_lookups_provider.dart | 2 +- .../screens/asset_alerts_screen.dart | 13 +- .../screens/asset_detail_screen.dart | 67 +- .../screens/asset_form_screen.dart | 633 +++++++++--------- .../screens/asset_list_screen.dart | 43 +- .../widgets/asset_maintenance_panel.dart | 203 +++--- .../widgets/asset_side_panels.dart | 50 +- .../presentation/screens/login_screen.dart | 321 ++++----- .../screens/reset_password_screen.dart | 2 +- .../widgets/login_hero_panel.dart | 174 ++--- .../screens/branch_form_screen.dart | 2 +- .../screens/company_form_screen.dart | 2 +- .../screens/dashboard_screen.dart | 100 +-- .../presentation/screens/grn_form_screen.dart | 21 +- .../master_consumer_invalidation.dart | 48 ++ .../providers/master_provider.dart | 5 + .../screens/master_list_screen.dart | 7 +- .../widgets/master_form_panel.dart | 3 +- .../widgets/master_inline_create_form.dart | 2 +- .../purchase_order_remote_data_source.dart | 4 +- .../purchase_order_repository_impl.dart | 4 +- .../purchase_order_repository.dart | 2 +- .../providers/purchase_orders_provider.dart | 7 +- .../screens/purchase_order_detail_screen.dart | 115 +++- .../screens/purchase_order_form_screen.dart | 34 +- .../screens/purchase_order_list_screen.dart | 18 +- .../screens/users_role_management_screen.dart | 141 ++-- .../presentation/widgets/add_user_panel.dart | 25 +- .../widgets/create_role_panel.dart | 2 +- .../presentation/widgets/rbac_widgets.dart | 1 + .../screens/user_form_screen.dart | 2 +- .../screens/users_roles_hub_screen.dart | 17 +- .../providers/vendor_lookups_provider.dart | 26 + .../screens/vendor_detail_screen.dart | 29 +- .../widgets/vendor_form_panel.dart | 24 +- .../widgets/vendor_sub_resource_panels.dart | 6 +- lib/shared/models/purchase_order_model.dart | 43 +- .../models/purchase_order_model.freezed.dart | 77 ++- lib/shared/models/purchase_order_model.g.dart | 2 + lib/shared/widgets/app_date_popup.dart | 528 ++++++++++++--- lib/shared/widgets/app_date_range_popup.dart | 541 ++++++++++++--- lib/shared/widgets/app_search_field.dart | 1 + lib/shared/widgets/app_segmented_tab_bar.dart | 173 +++++ lib/shared/widgets/app_side_panel.dart | 1 + .../widgets/app_sticky_form_layout.dart | 64 ++ .../widgets/master_inline_quick_add_form.dart | 2 +- test/core/validators_test.dart | 54 ++ 50 files changed, 2583 insertions(+), 1210 deletions(-) create mode 100644 lib/modules/master_data/presentation/providers/master_consumer_invalidation.dart create mode 100644 lib/modules/vendors/presentation/providers/vendor_lookups_provider.dart create mode 100644 lib/shared/widgets/app_segmented_tab_bar.dart create mode 100644 lib/shared/widgets/app_sticky_form_layout.dart diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart index b5daed2..b24990a 100644 --- a/lib/core/utils/formatters.dart +++ b/lib/core/utils/formatters.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; class DateFormatter { @@ -63,8 +64,10 @@ class DateFormatter { class CurrencyFormatter { CurrencyFormatter._(); + static const locale = 'en_IN'; + static final _formatter = NumberFormat.currency( - locale: 'en_IN', + locale: locale, symbol: '₹', decimalDigits: 2, ); @@ -74,11 +77,107 @@ class CurrencyFormatter { return _formatter.format(amount); } + /// Grouped amount for text fields (no currency symbol), e.g. `1,23,456.5`. + static String formatEditable(num? amount, {int maxDecimals = 2}) { + if (amount == null) return ''; + final value = amount.toDouble(); + if (value % 1 == 0) { + return NumberFormat('#,##,##0', locale).format(value); + } + return NumberFormat( + '#,##,##0.${'#' * maxDecimals}', + locale, + ).format(value); + } + + /// Removes grouping commas so values can be parsed / sent to the API. + static String stripGrouping(String value) => value.replaceAll(',', '').trim(); + + static double? tryParse(String? value) { + if (value == null) return null; + final cleaned = stripGrouping(value); + if (cleaned.isEmpty || cleaned == '.' || cleaned == '-') return null; + return double.tryParse(cleaned); + } + /// Formatted + raw numeric text for client-side column search. static String searchable(double? amount) { if (amount == null) return ''; return '${format(amount)} $amount'; } + + /// Input formatters for cost / amount fields (Indian comma grouping). + static List get amountInput => const [ + AmountThousandsSeparatorFormatter(), + ]; +} + +/// Formats numeric input with Indian-style thousand separators while typing. +/// +/// Example: `1234567.5` → `12,34,567.5` +class AmountThousandsSeparatorFormatter extends TextInputFormatter { + const AmountThousandsSeparatorFormatter({this.maxDecimals = 2}); + + final int maxDecimals; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final raw = newValue.text; + if (raw.isEmpty) { + return newValue; + } + + // Keep only digits and a single decimal point. + final buffer = StringBuffer(); + var seenDot = false; + var decimals = 0; + for (final rune in raw.runes) { + final ch = String.fromCharCode(rune); + if (ch == ',') continue; + if (ch == '.') { + if (seenDot) continue; + seenDot = true; + buffer.write(ch); + continue; + } + if (ch.compareTo('0') >= 0 && ch.compareTo('9') <= 0) { + if (seenDot) { + if (decimals >= maxDecimals) continue; + decimals++; + } + buffer.write(ch); + } + } + + final cleaned = buffer.toString(); + if (cleaned.isEmpty) { + return const TextEditingValue( + text: '', + selection: TextSelection.collapsed(offset: 0), + ); + } + + final parts = cleaned.split('.'); + final intPart = parts[0]; + final hasDot = cleaned.contains('.'); + final fracPart = parts.length > 1 ? parts[1] : ''; + + final formattedInt = intPart.isEmpty + ? '' + : NumberFormat('#,##,##0', CurrencyFormatter.locale) + .format(int.parse(intPart)); + + final formatted = hasDot ? '$formattedInt.$fracPart' : formattedInt; + + // Place caret at end; stable enough for amount entry. + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } } /// Converts `snake_case` / `kebab-case` keys into readable Title Case labels. diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart index c8ad0d8..9ce152e 100644 --- a/lib/core/utils/validators.dart +++ b/lib/core/utils/validators.dart @@ -1,5 +1,7 @@ import 'package:flutter/services.dart'; +import 'formatters.dart'; + class Validators { Validators._(); @@ -137,15 +139,35 @@ class Validators { return null; } + /// Strong password policy used for create / change / reset flows. + /// + /// Requires: 8+ chars, uppercase, lowercase, digit, and a special character. static String? password(String? value) { if (value == null || value.isEmpty) return 'Password is required'; - if (value.length < 8) return 'Password must be at least 8 characters'; - if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Must contain an uppercase letter'; - if (!RegExp(r'[a-z]').hasMatch(value)) return 'Must contain a lowercase letter'; - if (!RegExp(r'[0-9]').hasMatch(value)) return 'Must contain a number'; + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + if (!RegExp(r'[A-Z]').hasMatch(value)) { + return 'Password must contain at least one uppercase letter (A–Z)'; + } + if (!RegExp(r'[a-z]').hasMatch(value)) { + return 'Password must contain at least one lowercase letter (a–z)'; + } + if (!RegExp(r'[0-9]').hasMatch(value)) { + return 'Password must contain at least one numeric digit (0–9)'; + } + if (!RegExp(r'''[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\;/`'~]''').hasMatch(value)) { + return 'Password must contain at least one special character (e.g. @, #, \$, %, &, !)'; + } return null; } + /// Same as [password], but allows empty (e.g. edit user keep current password). + static String? optionalPassword(String? value) { + if (value == null || value.isEmpty) return null; + return password(value); + } + /// Required 15-character GSTIN pattern. static String? gstin(String? value) { if (value == null || value.trim().isEmpty) { @@ -212,7 +234,7 @@ class Validators { String fieldName = 'Value', }) { if (value == null || value.trim().isEmpty) return null; - final parsed = double.tryParse(value.trim()); + final parsed = CurrencyFormatter.tryParse(value); if (parsed == null) return 'Enter a valid number'; if (parsed <= 0) return '$fieldName must be greater than 0'; return null; @@ -224,7 +246,7 @@ class Validators { String fieldName = 'Value', }) { if (value == null || value.trim().isEmpty) return null; - final parsed = double.tryParse(value.trim()); + final parsed = CurrencyFormatter.tryParse(value); if (parsed == null) return 'Enter a valid number'; if (parsed < 0) return '$fieldName cannot be negative'; return null; @@ -248,7 +270,7 @@ class Validators { String fieldName = 'Percentage', }) { if (value == null || value.trim().isEmpty) return null; - final parsed = double.tryParse(value.trim()); + final parsed = CurrencyFormatter.tryParse(value); if (parsed == null) return 'Enter a valid percentage'; if (parsed < 0 || parsed > 100) { return '$fieldName must be between 0 and 100'; diff --git a/lib/modules/assets/presentation/providers/asset_categories_provider.dart b/lib/modules/assets/presentation/providers/asset_categories_provider.dart index 215af11..9f94564 100644 --- a/lib/modules/assets/presentation/providers/asset_categories_provider.dart +++ b/lib/modules/assets/presentation/providers/asset_categories_provider.dart @@ -2,9 +2,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/repositories/asset_repository_impl.dart'; import '../../../../shared/models/asset_model.dart'; +import '../../../../shared/models/user_management_models.dart'; +import '../../../masters/data/datasources/master_remote_data_source.dart'; /// Categories for Assets list filter (paginated list API — no dropdown_call). -final itemCategoriesProvider = FutureProvider>((ref) async { +final itemCategoriesProvider = + FutureProvider.autoDispose>((ref) async { final repository = ref.watch(assetRepositoryProvider); final result = await repository.getCategories(); if (result.failure != null) throw result.failure!; @@ -13,12 +16,20 @@ final itemCategoriesProvider = FutureProvider>((ref) as /// Categories for Asset form dropdowns (`dropdown_call=true`). final itemCategoriesFormProvider = - FutureProvider>((ref) async { + FutureProvider.autoDispose>((ref) async { final repository = ref.watch(assetRepositoryProvider); final result = await repository.getCategories(dropdownCall: true); if (result.failure != null) throw result.failure!; return result.data ?? []; }); +/// Subcategories for a selected item category (Asset form). +final itemSubcategoriesProvider = FutureProvider.autoDispose + .family, int?>((ref, categoryId) async { + if (categoryId == null) return []; + final dataSource = ref.watch(masterRemoteDataSourceProvider); + return dataSource.listItemSubcategories(itemCategoryId: categoryId); +}); + @Deprecated('Use itemCategoriesProvider') final assetCategoriesProvider = itemCategoriesProvider; diff --git a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart index 8490da2..86ec7d8 100644 --- a/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart +++ b/lib/modules/assets/presentation/providers/asset_form_lookups_provider.dart @@ -69,7 +69,7 @@ final assetFormLookupsProvider = /// Lightweight lookups for Asset Master list filters only. /// Avoids [assetFormLookupsProvider] (PO/GRN/vendors/users/options) on the list. -final assetListFilterLookupsProvider = FutureProvider< +final assetListFilterLookupsProvider = FutureProvider.autoDispose< ({ List locations, List statuses, diff --git a/lib/modules/assets/presentation/screens/asset_alerts_screen.dart b/lib/modules/assets/presentation/screens/asset_alerts_screen.dart index e5ca13c..d6fb217 100644 --- a/lib/modules/assets/presentation/screens/asset_alerts_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_alerts_screen.dart @@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/widgets/app_segmented_tab_bar.dart'; import '../providers/assets_provider.dart'; class AssetAlertsScreen extends ConsumerStatefulWidget { @@ -64,11 +65,17 @@ class _AssetAlertsScreenState extends ConsumerState ), ], ), - TabBar( + AppSegmentedTabBar( controller: _tabController, tabs: const [ - Tab(text: 'Expiry Alerts'), - Tab(text: 'Service Alerts'), + AppSegmentedTab( + label: 'Expiry Alerts', + icon: Icons.event_busy_outlined, + ), + AppSegmentedTab( + label: 'Service Alerts', + icon: Icons.build_circle_outlined, + ), ], ), const SizedBox(height: 12), diff --git a/lib/modules/assets/presentation/screens/asset_detail_screen.dart b/lib/modules/assets/presentation/screens/asset_detail_screen.dart index 5d4ee30..799ee92 100644 --- a/lib/modules/assets/presentation/screens/asset_detail_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_detail_screen.dart @@ -20,6 +20,7 @@ import '../../../../shared/widgets/entity_attachments_card.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/app_side_panel.dart'; +import '../../../../shared/widgets/app_segmented_tab_bar.dart'; import '../providers/assets_provider.dart'; import '../providers/asset_form_lookups_provider.dart'; import 'asset_form_screen.dart'; @@ -122,13 +123,25 @@ class _AssetDetailScreenState extends ConsumerState ], ), const SizedBox(height: 16), - TabBar( + AppSegmentedTabBar( controller: _tabController, tabs: const [ - Tab(text: 'Overview'), - Tab(text: 'AMC'), - Tab(text: 'Service Visits'), - Tab(text: 'Insurance'), + AppSegmentedTab( + label: 'Overview', + icon: Icons.dashboard_outlined, + ), + AppSegmentedTab( + label: 'AMC', + icon: Icons.handshake_outlined, + ), + AppSegmentedTab( + label: 'Service Visits', + icon: Icons.build_outlined, + ), + AppSegmentedTab( + label: 'Insurance', + icon: Icons.health_and_safety_outlined, + ), ], ), const SizedBox(height: 16), @@ -350,28 +363,32 @@ class _OverviewTab extends ConsumerWidget { padding: EdgeInsets.symmetric(vertical: 16), child: Divider(height: 1), ), - Align( - alignment: Alignment.centerLeft, - child: OutlinedButton.icon( - onPressed: () async { - final saved = await openSubmitMaintenancePanel( - context, - ref, - asset: asset, - ); - if (saved == true && context.mounted) { - ref.invalidate(myMaintenanceProvider); - showAppToastFromSnackBar( + AssetRecentMaintenanceLogsSection( + assetId: assetId, + leadingActions: [ + OutlinedButton.icon( + onPressed: () async { + final saved = await openSubmitMaintenancePanel( context, - const SnackBar( - content: Text('Maintenance log submitted'), - ), + ref, + asset: asset, ); - } - }, - icon: const Icon(Icons.checklist_outlined), - label: const Text('Log Maintenance'), - ), + if (saved == true && context.mounted) { + ref.invalidate(myMaintenanceProvider); + ref.invalidate(assetDetailProvider(assetId)); + showAppToastFromSnackBar( + context, + const SnackBar( + content: + Text('Maintenance log submitted'), + ), + ); + } + }, + icon: const Icon(Icons.checklist_outlined), + label: const Text('Log Maintenance'), + ), + ], ), ], if (asset.remarks?.trim().isNotEmpty == true) ...[ diff --git a/lib/modules/assets/presentation/screens/asset_form_screen.dart b/lib/modules/assets/presentation/screens/asset_form_screen.dart index 632cd74..2a3654b 100644 --- a/lib/modules/assets/presentation/screens/asset_form_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_form_screen.dart @@ -19,10 +19,10 @@ import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; +import '../../../../shared/widgets/app_sticky_form_layout.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/error_view.dart'; -import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../../data/repositories/asset_repository_impl.dart'; import '../providers/asset_categories_provider.dart'; @@ -30,7 +30,7 @@ import '../providers/asset_form_lookups_provider.dart'; import '../providers/assets_provider.dart'; /// Opens the full-page asset create/edit screen (same pattern as GRN form). -/// [ref] is kept for call-site compatibility; edit refetch happens in [AssetFormScreen]. +/// [ref] is kept for call-site compatibility; edit refetch happens in [AssetFormScreen]. void openAssetForm( BuildContext context, WidgetRef ref, { @@ -109,6 +109,11 @@ class _AssetFormScreenState extends ConsumerState { bool _isPopulatingForm = false; bool _requestedFreshLoad = false; + bool get _isDisposalStatus { + final status = _status?.trim().toUpperCase(); + return status == 'DISPOSED' || status == 'SCRAPPED'; + } + @override void initState() { super.initState(); @@ -185,9 +190,7 @@ class _AssetFormScreenState extends ConsumerState { String key, String value, ) { - final trimmed = value.trim(); - if (trimmed.isEmpty) return; - final parsed = double.tryParse(trimmed); + final parsed = CurrencyFormatter.tryParse(value); if (parsed != null) payload[key] = parsed; } @@ -235,20 +238,17 @@ class _AssetFormScreenState extends ConsumerState { _locationDetailController.text = asset.locationDetail ?? ''; _qrCodeController.text = asset.qrCodeValue ?? ''; _disposalReasonController.text = asset.disposalReason ?? ''; - _disposalValueController.text = asset.disposalValue?.toString() ?? ''; + _disposalValueController.text = + CurrencyFormatter.formatEditable(asset.disposalValue); _usefulLifeController.text = asset.usefulLifeYears?.toString() ?? ''; _depreciationRateController.text = asset.depreciationRate?.toString() ?? ''; - _salvageValueController.text = asset.salvageValue?.toString() ?? ''; + _salvageValueController.text = + CurrencyFormatter.formatEditable(asset.salvageValue); _remarksController.text = asset.remarks ?? ''; _frequencyController.text = asset.maintenanceFrequencyInDays?.toString() ?? ''; - if (asset.purchaseCost != null) { - _costController.text = asset.purchaseCost!.toStringAsFixed( - asset.purchaseCost! % 1 == 0 ? 0 : 2, - ); - } else { - _costController.clear(); - } + _costController.text = + CurrencyFormatter.formatEditable(asset.purchaseCost); }); _isPopulatingForm = false; _triggerPreviewRecalculation(); @@ -269,7 +269,7 @@ class _AssetFormScreenState extends ConsumerState { return null; } - final rate = double.tryParse(_depreciationRateController.text.trim()); + final rate = CurrencyFormatter.tryParse(_depreciationRateController.text); if (method == 'OTHER' && rate == null) { return null; } @@ -279,10 +279,11 @@ class _AssetFormScreenState extends ConsumerState { if (rate != null) 'depreciation_rate': rate, }; - final purchaseCost = double.tryParse(_costController.text.trim()); + final purchaseCost = CurrencyFormatter.tryParse(_costController.text); if (purchaseCost != null) payload['purchase_cost'] = purchaseCost; - final salvageValue = double.tryParse(_salvageValueController.text.trim()); + final salvageValue = + CurrencyFormatter.tryParse(_salvageValueController.text); if (salvageValue != null) payload['salvage_value'] = salvageValue; final usefulLife = int.tryParse(_usefulLifeController.text.trim()); @@ -510,8 +511,9 @@ class _AssetFormScreenState extends ConsumerState { return 'Warranty expiry must be on or after purchase date'; } - final purchaseCost = double.tryParse(_costController.text.trim()); - final salvageValue = double.tryParse(_salvageValueController.text.trim()); + final purchaseCost = CurrencyFormatter.tryParse(_costController.text); + final salvageValue = + CurrencyFormatter.tryParse(_salvageValueController.text); if (purchaseCost != null && salvageValue != null && salvageValue > purchaseCost) { @@ -530,7 +532,7 @@ class _AssetFormScreenState extends ConsumerState { } } - final isDisposed = _status == 'DISPOSED' || _status == 'SCRAPPED'; + final isDisposed = _isDisposalStatus; if (isDisposed) { if (_disposalDate == null) { return 'Disposal date is required for disposed or scrapped assets'; @@ -634,7 +636,7 @@ class _AssetFormScreenState extends ConsumerState { ); return Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: EdgeInsets.zero, child: LayoutBuilder( builder: (context, constraints) { final stack = constraints.maxWidth < 720; @@ -715,16 +717,14 @@ class _AssetFormScreenState extends ConsumerState { return Form( key: _formKey, - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), - child: Column( + child: AppStickyFormLayout( + scrollController: _scrollController, + header: _buildHeader(), + body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildHeader(), - const SizedBox(height: 8), SidePanelSection( - title: 'BASIC DETAILS', + title: 'ASSET DETAILS', children: [ FormRowFour( children: [ @@ -764,67 +764,6 @@ class _AssetFormScreenState extends ConsumerState { ), ], ), - SidePanelSection( - title: 'IDENTIFICATION', - children: [ - FormRowFour( - children: [ - AppTextField( - isDense: true, - controller: _serialController, - label: 'Serial Number', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Serial Number', - ); - }, - ), - AppTextField( - isDense: true, - controller: _partNumberController, - label: 'Part Number', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Part Number', - ); - }, - ), - AppTextField( - isDense: true, - controller: _brandModelController, - label: 'Brand / Model', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Brand / Model', - ); - }, - ), - AppTextField( - isDense: true, - controller: _manufacturerController, - label: 'Manufacturer', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Manufacturer', - ); - }, - ), - ], - ), - ], - ), lookupsAsync.when( loading: () => const Padding( padding: EdgeInsets.symmetric(vertical: 12), @@ -837,73 +776,6 @@ class _AssetFormScreenState extends ConsumerState { data: (lookups) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SidePanelSection( - title: 'LOCATION & ASSIGNMENT', - children: [ - FormRowFour( - children: [ - _optionalLookupDropdown( - label: 'Department', - value: _departmentId, - options: lookups.departments, - masterId: 'departments', - onChanged: (v) => - setState(() => _departmentId = v), - ), - AppTextField( - isDense: true, - controller: _locationDetailController, - label: 'Location Detail', - validator: (v) { - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 2, - fieldName: 'Location Detail', - ); - }, - ), - _optionalLookupDropdown( - label: 'Assigned To', - value: _assignedToUserId, - options: lookups.users, - onChanged: (v) => - setState(() => _assignedToUserId = v), - emptyHint: 'Unassigned', - ), - ], - ), - ], - ), - SidePanelSection( - title: 'MAINTENANCE', - children: [ - FormRowFour( - children: [ - _optionalLookupDropdown( - label: 'Maintenance Incharge', - value: _maintenanceInchargeUserId, - options: lookups.users, - onChanged: (v) => setState( - () => _maintenanceInchargeUserId = v, - ), - emptyHint: 'Unassigned', - ), - AppTextField( - isDense: true, - controller: _frequencyController, - label: 'Frequency (Days)', - keyboardType: TextInputType.number, - validator: (v) => Validators.optionalPositiveInt( - v, - fieldName: 'Frequency', - ), - ), - ], - ), - _buildChecklistEditor(), - ], - ), SidePanelSection( title: 'PROCUREMENT', children: [ @@ -957,104 +829,242 @@ class _AssetFormScreenState extends ConsumerState { ), ], ), + SidePanelSection( + title: 'PURCHASE & DEPRECIATION', + children: [ + FormRowFour( + children: [ + _AssetFormDateField( + label: 'Purchase Date', + value: _purchaseDate, + onPick: () => _pickDate( + (d) => _purchaseDate = d, + _purchaseDate, + ), + ), + _AssetFormDateField( + label: 'Commencement Date', + value: _commencementDate, + onPick: () => _pickDate( + (d) => _commencementDate = d, + _commencementDate, + ), + ), + _AssetFormDateField( + label: 'Warranty Expiry Date', + value: _warrantyExpiry, + onPick: () => _pickDate( + (d) => _warrantyExpiry = d, + _warrantyExpiry, + ), + ), + AppTextField( + isDense: true, + controller: _costController, + label: 'Purchase Cost', + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: CurrencyFormatter.amountInput, + validator: (v) => Validators.optionalPositiveDouble( + v, + fieldName: 'Purchase Cost', + ), + ), + ], + ), + FormRowFour( + children: [ + AppTextField( + isDense: true, + controller: _usefulLifeController, + label: 'Useful Life (Years)', + keyboardType: TextInputType.number, + validator: (v) => Validators.optionalPositiveInt( + v, + fieldName: 'Useful Life', + ), + ), + AppDropdown( + isDense: true, + label: 'Depreciation Method', + value: _depreciationMethod, + options: assetOptionDropdowns(depreciationMethods), + enabled: depreciationMethods.isNotEmpty, + hint: depreciationMethods.isEmpty + ? 'Loading depreciation methods...' + : null, + onChanged: (v) => setState(() { + _depreciationMethod = v; + _triggerPreviewRecalculation(); + }), + ), + AppTextField( + isDense: true, + controller: _depreciationRateController, + label: 'Depreciation Rate (%)', + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (v) => Validators.optionalPercentage( + v, + fieldName: 'Depreciation Rate', + ), + ), + AppTextField( + isDense: true, + controller: _salvageValueController, + label: 'Salvage Value', + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: CurrencyFormatter.amountInput, + validator: (v) => + Validators.optionalNonNegativeDouble( + v, + fieldName: 'Salvage Value', + ), + ), + ], + ), + _DepreciationPreviewCard( + preview: _depreciationPreview, + isLoading: _isDepreciationPreviewLoading, + error: _depreciationPreviewError, + ), + ], + ), + SidePanelSection( + title: 'LOCATION & ASSIGNMENT', + children: [ + FormRowFour( + children: [ + _optionalLookupDropdown( + label: 'Department', + value: _departmentId, + options: lookups.departments, + masterId: 'departments', + onChanged: (v) => + setState(() => _departmentId = v), + ), + AppTextField( + isDense: true, + controller: _locationDetailController, + label: 'Location Detail', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Location Detail', + ); + }, + ), + _optionalLookupDropdown( + label: 'Assigned To', + value: _assignedToUserId, + options: lookups.users, + onChanged: (v) => + setState(() => _assignedToUserId = v), + emptyHint: 'Unassigned', + ), + ], + ), + ], + ), + SidePanelSection( + title: 'IDENTIFICATION', + children: [ + FormRowFour( + children: [ + AppTextField( + isDense: true, + controller: _serialController, + label: 'Serial Number', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Serial Number', + ); + }, + ), + AppTextField( + isDense: true, + controller: _partNumberController, + label: 'Part Number', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Part Number', + ); + }, + ), + AppTextField( + isDense: true, + controller: _brandModelController, + label: 'Brand / Model', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Brand / Model', + ); + }, + ), + AppTextField( + isDense: true, + controller: _manufacturerController, + label: 'Manufacturer', + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 2, + fieldName: 'Manufacturer', + ); + }, + ), + ], + ), + ], + ), + SidePanelSection( + title: 'MAINTENANCE', + children: [ + FormRowFour( + children: [ + _optionalLookupDropdown( + label: 'Maintenance Incharge', + value: _maintenanceInchargeUserId, + options: lookups.users, + onChanged: (v) => setState( + () => _maintenanceInchargeUserId = v, + ), + emptyHint: 'Unassigned', + ), + AppTextField( + isDense: true, + controller: _frequencyController, + label: 'Frequency (Days)', + keyboardType: TextInputType.number, + validator: (v) => Validators.optionalPositiveInt( + v, + fieldName: 'Frequency', + ), + ), + ], + ), + _buildChecklistEditor(), + ], + ), ], ), ), - SidePanelSection( - title: 'PURCHASE & DEPRECIATION', - children: [ - FormRowFour( - children: [ - _AssetFormDateField( - label: 'Purchase Date', - value: _purchaseDate, - onPick: () => - _pickDate((d) => _purchaseDate = d, _purchaseDate), - ), - _AssetFormDateField( - label: 'Commencement Date', - value: _commencementDate, - onPick: () => _pickDate( - (d) => _commencementDate = d, - _commencementDate, - ), - ), - _AssetFormDateField( - label: 'Warranty Expiry Date', - value: _warrantyExpiry, - onPick: () => - _pickDate((d) => _warrantyExpiry = d, _warrantyExpiry), - ), - AppTextField( - isDense: true, - controller: _costController, - label: 'Purchase Cost', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (v) => Validators.optionalPositiveDouble( - v, - fieldName: 'Purchase Cost', - ), - ), - ], - ), - FormRowFour( - children: [ - AppTextField( - isDense: true, - controller: _usefulLifeController, - label: 'Useful Life (Years)', - keyboardType: TextInputType.number, - validator: (v) => Validators.optionalPositiveInt( - v, - fieldName: 'Useful Life', - ), - ), - AppDropdown( - isDense: true, - label: 'Depreciation Method', - value: _depreciationMethod, - options: assetOptionDropdowns(depreciationMethods), - enabled: depreciationMethods.isNotEmpty, - hint: depreciationMethods.isEmpty - ? 'Loading depreciation methods...' - : null, - onChanged: (v) => setState(() { - _depreciationMethod = v; - _triggerPreviewRecalculation(); - }), - ), - AppTextField( - isDense: true, - controller: _depreciationRateController, - label: 'Depreciation Rate (%)', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (v) => Validators.optionalPercentage( - v, - fieldName: 'Depreciation Rate', - ), - ), - AppTextField( - isDense: true, - controller: _salvageValueController, - label: 'Salvage Value', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (v) => Validators.optionalNonNegativeDouble( - v, - fieldName: 'Salvage Value', - ), - ), - ], - ), - _DepreciationPreviewCard( - preview: _depreciationPreview, - isLoading: _isDepreciationPreviewLoading, - error: _depreciationPreviewError, - ), - ], - ), SidePanelSection( title: 'STATUS', children: [ @@ -1076,7 +1086,14 @@ class _AssetFormScreenState extends ConsumerState { value: _status, options: assetOptionDropdowns(statuses), enabled: statuses.isNotEmpty, - onChanged: (v) => setState(() => _status = v), + onChanged: (v) => setState(() { + _status = v; + if (!_isDisposalStatus) { + _disposalDate = null; + _disposalValueController.clear(); + _disposalReasonController.clear(); + } + }), validator: (v) => v == null ? 'Status is required' : null, ), AppFormToggleField( @@ -1089,62 +1106,61 @@ class _AssetFormScreenState extends ConsumerState { ), ], ), - SidePanelSection( - title: 'DISPOSAL', - children: [ - FormRowFour( - children: [ - _AssetFormDateField( - label: 'Disposal Date', - value: _disposalDate, - onPick: () => - _pickDate((d) => _disposalDate = d, _disposalDate), - validator: () { - final isDisposed = - _status == 'DISPOSED' || _status == 'SCRAPPED'; - if (isDisposed && _disposalDate == null) { - return 'Disposal date is required'; - } - return null; - }, - ), - AppTextField( - isDense: true, - controller: _disposalValueController, - label: 'Disposal Value', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (v) => Validators.optionalNonNegativeDouble( - v, - fieldName: 'Disposal Value', + if (_isDisposalStatus) + SidePanelSection( + title: 'DISPOSAL', + children: [ + FormRowFour( + children: [ + _AssetFormDateField( + label: 'Disposal Date', + value: _disposalDate, + onPick: () => + _pickDate((d) => _disposalDate = d, _disposalDate), + validator: () { + if (_isDisposalStatus && _disposalDate == null) { + return 'Disposal date is required'; + } + return null; + }, ), - ), - AppTextField( - isDense: true, - controller: _disposalReasonController, - label: 'Disposal Reason', - maxLines: 2, - validator: (v) { - final isDisposed = - _status == 'DISPOSED' || _status == 'SCRAPPED'; - if (isDisposed) { - return Validators.required( - v, + AppTextField( + isDense: true, + controller: _disposalValueController, + label: 'Disposal Value', + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: CurrencyFormatter.amountInput, + validator: (v) => Validators.optionalNonNegativeDouble( + v, + fieldName: 'Disposal Value', + ), + ), + AppTextField( + isDense: true, + controller: _disposalReasonController, + label: 'Disposal Reason', + maxLines: 2, + validator: (v) { + if (_isDisposalStatus) { + return Validators.required( + v, + fieldName: 'Disposal Reason', + ); + } + if (v == null || v.trim().isEmpty) return null; + return Validators.minLength( + v.trim(), + 3, fieldName: 'Disposal Reason', ); - } - if (v == null || v.trim().isEmpty) return null; - return Validators.minLength( - v.trim(), - 3, - fieldName: 'Disposal Reason', - ); - }, - ), - ], - ), - ], - ), + }, + ), + ], + ), + ], + ), SidePanelSection( title: 'OTHER', children: [ @@ -1715,10 +1731,3 @@ class _AssetFormDateFieldState extends State<_AssetFormDateField> { ); } } - -final itemSubcategoriesProvider = - FutureProvider.family, int?>((ref, categoryId) async { - if (categoryId == null) return []; - final dataSource = ref.watch(masterRemoteDataSourceProvider); - return dataSource.listItemSubcategories(itemCategoryId: categoryId); -}); diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index 31e633f..fb459e5 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -406,10 +406,7 @@ class _AssetDataTable extends StatelessWidget { cellBuilder: (_, asset) { final code = asset.assetCode; if (code == null || code.isEmpty) return const Text('—'); - return _AssetCodeBadge( - code: code, - onTap: () => onView(asset), - ); + return AppTableCell.link(code, onTap: () => onView(asset)); }, ), AppDataColumn( @@ -431,7 +428,7 @@ class _AssetDataTable extends StatelessWidget { cellBuilder: (_, asset) => Text(asset.locationName ?? '—'), ), AppDataColumn( - label: 'Warranty', + label: 'Warranty Validity', flex: 1, searchText: (asset) => DateFormatter.searchableDate(asset.warrantyExpiryDate), @@ -539,8 +536,8 @@ class _AssetMobileList extends StatelessWidget { ), const SizedBox(height: 4), if (asset.assetCode != null && asset.assetCode!.isNotEmpty) - _AssetCodeBadge( - code: asset.assetCode!, + AppTableCell.link( + asset.assetCode!, onTap: () => onView(asset), ) else @@ -577,35 +574,3 @@ class _AssetMobileList extends StatelessWidget { ); } } - -class _AssetCodeBadge extends StatelessWidget { - const _AssetCodeBadge({required this.code, this.onTap}); - - final String code; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final badge = Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(20), - ), - child: AppTableCell.link( - code, - onTap: onTap, - underlined: false, - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ); - if (onTap == null) return badge; - return MouseRegion( - cursor: SystemMouseCursors.click, - child: badge, - ); - } -} diff --git a/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart b/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart index fbafc6a..8ae2358 100644 --- a/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_maintenance_panel.dart @@ -65,10 +65,6 @@ class _SubmitMaintenanceLogPanelState DateTime _performedDate = DateTime.now(); late final List<_ChecklistRowState> _rows; bool _isSubmitting = false; - bool _showLogs = false; - List? _logs; - bool _logsLoading = false; - String? _logsError; @override void initState() { @@ -107,27 +103,6 @@ class _SubmitMaintenanceLogPanelState } } - Future _loadLogs() async { - setState(() { - _showLogs = true; - _logsLoading = true; - _logsError = null; - }); - final result = await ref - .read(assetRepositoryProvider) - .getMaintenanceLogs(widget.asset.id); - if (!mounted) return; - setState(() { - _logsLoading = false; - if (result.failure != null) { - _logsError = result.failure!.message; - _logs = null; - } else { - _logs = result.data ?? []; - } - }); - } - Future _save() async { if (!_formKey.currentState!.validate()) return; if (_rows.isEmpty) { @@ -282,61 +257,7 @@ class _SubmitMaintenanceLogPanelState ], ), const SizedBox(height: 16), - Row( - children: [ - Text( - 'Recent Logs', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const Spacer(), - TextButton.icon( - onPressed: _logsLoading - ? null - : () { - if (_showLogs && _logs != null) { - setState(() => _showLogs = !_showLogs); - } else { - _loadLogs(); - } - }, - icon: Icon( - _showLogs ? Icons.expand_less : Icons.history, - size: 18, - ), - label: Text(_showLogs ? 'Hide' : 'View'), - ), - ], - ), - if (_showLogs) ...[ - const SizedBox(height: 8), - if (_logsLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator()), - ) - else if (_logsError != null) - Text( - _logsError!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ) - else if (_logs == null || _logs!.isEmpty) - const AppEmptyState( - title: 'No logs yet', - description: 'Submitted maintenance visits will appear here.', - icon: Icons.history_toggle_off_outlined, - ) - else - ..._logs!.take(5).map( - (log) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _MaintenanceLogTile(log: log), - ), - ), - ], + AssetRecentMaintenanceLogsSection(assetId: widget.asset.id), ], ), ), @@ -344,6 +265,128 @@ class _SubmitMaintenanceLogPanelState } } +/// Expandable recent maintenance logs (asset detail + submit panel). +class AssetRecentMaintenanceLogsSection extends ConsumerStatefulWidget { + const AssetRecentMaintenanceLogsSection({ + super.key, + required this.assetId, + this.limit = 5, + this.leadingActions, + }); + + final String assetId; + final int limit; + + /// Optional actions shown on the same row (e.g. Log Maintenance). + final List? leadingActions; + + @override + ConsumerState createState() => + _AssetRecentMaintenanceLogsSectionState(); +} + +class _AssetRecentMaintenanceLogsSectionState + extends ConsumerState { + bool _showLogs = false; + List? _logs; + bool _logsLoading = false; + String? _logsError; + + Future _loadLogs() async { + setState(() { + _showLogs = true; + _logsLoading = true; + _logsError = null; + }); + final result = await ref + .read(assetRepositoryProvider) + .getMaintenanceLogs(widget.assetId); + if (!mounted) return; + setState(() { + _logsLoading = false; + if (result.failure != null) { + _logsError = result.failure!.message; + _logs = null; + } else { + _logs = result.data ?? []; + } + }); + } + + void _toggle() { + if (_showLogs && _logs != null) { + setState(() => _showLogs = !_showLogs); + return; + } + _loadLogs(); + } + + @override + Widget build(BuildContext context) { + // Refresh open logs after a new maintenance submit updates the asset. + ref.listen(assetDetailProvider(widget.assetId), (previous, next) { + if (_showLogs && previous != next) { + _loadLogs(); + } + }); + + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...?widget.leadingActions, + OutlinedButton.icon( + onPressed: _logsLoading ? null : _toggle, + icon: Icon( + _showLogs ? Icons.expand_less : Icons.history, + size: 18, + ), + label: const Text('Recent Logs'), + ), + ], + ), + ), + if (_showLogs) ...[ + const SizedBox(height: 12), + if (_logsLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (_logsError != null) + Text( + _logsError!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ) + else if (_logs == null || _logs!.isEmpty) + const AppEmptyState( + title: 'No logs yet', + description: 'Submitted maintenance visits will appear here.', + icon: Icons.history_toggle_off_outlined, + ) + else + ..._logs!.take(widget.limit).map( + (log) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _MaintenanceLogTile(log: log), + ), + ), + ], + ], + ); + } +} + class _DateField extends StatelessWidget { const _DateField({ required this.label, diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index 1aaa45b..c5f1d63 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -82,7 +82,8 @@ class _AddAmcPanelState extends ConsumerState { _endDate = contract.endDate; _renewalDate = contract.renewalDate; if (contract.annualCost != null) { - _annualCostController.text = contract.annualCost.toString(); + _annualCostController.text = + CurrencyFormatter.formatEditable(contract.annualCost); } _paymentFrequency = contract.paymentFrequency; _serviceFrequency = contract.serviceFrequency; @@ -99,7 +100,7 @@ class _AddAmcPanelState extends ConsumerState { } Map _buildPayload() { - final annualCost = double.tryParse(_annualCostController.text.trim()); + final annualCost = CurrencyFormatter.tryParse(_annualCostController.text); final visitsPerYear = int.tryParse(_visitsPerYearController.text.trim()); return { 'vendor_id': _vendorId, @@ -324,6 +325,11 @@ class _AddAmcPanelState extends ConsumerState { controller: _annualCostController, label: 'Annual Cost', keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: CurrencyFormatter.amountInput, + validator: (v) => Validators.optionalNonNegativeDouble( + v, + fieldName: 'Annual Cost', + ), ), ), const SizedBox(height: 12), @@ -420,7 +426,7 @@ class _AddAmcPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: true, - saveLabel: 'Edit AMC Contract', + saveLabel: 'Update AMC Contract', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), @@ -436,7 +442,7 @@ class _AddAmcPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Edit AMC Contract', + saveLabel: 'Update AMC Contract', onSave: _save, ), child: _buildForm(), @@ -450,7 +456,7 @@ class _AddAmcPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Add AMC Contract', + saveLabel: 'Save AMC Contract', onSave: _save, ), child: _buildForm(), @@ -534,7 +540,8 @@ class _LogServiceVisitPanelState extends ConsumerState { _downtimeHoursController.text = visit.downtimeHours.toString(); } if (visit.serviceCost != null) { - _serviceCostController.text = visit.serviceCost.toString(); + _serviceCostController.text = + CurrencyFormatter.formatEditable(visit.serviceCost); } _isUnderAmc = visit.isUnderAmc; _assetConditionAfter = visit.assetConditionAfter; @@ -558,8 +565,10 @@ class _LogServiceVisitPanelState extends ConsumerState { } Map _buildPayload() { - final downtimeHours = double.tryParse(_downtimeHoursController.text.trim()); - final serviceCost = double.tryParse(_serviceCostController.text.trim()); + final downtimeHours = + CurrencyFormatter.tryParse(_downtimeHoursController.text); + final serviceCost = + CurrencyFormatter.tryParse(_serviceCostController.text); return { if (_visitType != null && _visitType!.trim().isNotEmpty) 'visit_type': _visitType, 'visit_date': DateFormatter.toApiDate(_visitDate!), @@ -806,6 +815,7 @@ class _LogServiceVisitPanelState extends ConsumerState { controller: _serviceCostController, label: 'Service Cost', keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: CurrencyFormatter.amountInput, validator: (v) => Validators.optionalPositiveDouble( v, fieldName: 'Service Cost', @@ -843,7 +853,7 @@ class _LogServiceVisitPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: true, - saveLabel: 'Edit Service Visit', + saveLabel: 'Update Service Visit', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), @@ -859,7 +869,7 @@ class _LogServiceVisitPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Edit Service Visit', + saveLabel: 'Update Service Visit', onSave: _save, ), child: _buildForm(widget.amcContracts), @@ -945,10 +955,12 @@ class _AddInsurancePanelState extends ConsumerState { _insurerEmailController.text = policy.insurerEmail ?? ''; _policyType = policy.policyType; if (policy.sumInsured != null) { - _sumInsuredController.text = policy.sumInsured.toString(); + _sumInsuredController.text = + CurrencyFormatter.formatEditable(policy.sumInsured); } if (policy.annualPremium != null) { - _annualPremiumController.text = policy.annualPremium.toString(); + _annualPremiumController.text = + CurrencyFormatter.formatEditable(policy.annualPremium); } _startDate = policy.policyStartDate; _endDate = policy.policyEndDate; @@ -1002,8 +1014,10 @@ class _AddInsurancePanelState extends ConsumerState { } Map _buildPayload() { - final sumInsured = double.tryParse(_sumInsuredController.text.trim()); - final annualPremium = double.tryParse(_annualPremiumController.text.trim()); + final sumInsured = + CurrencyFormatter.tryParse(_sumInsuredController.text); + final annualPremium = + CurrencyFormatter.tryParse(_annualPremiumController.text); return { 'policy_no': _policyNoController.text.trim(), 'insurer_name': _insurerNameController.text.trim(), @@ -1141,6 +1155,7 @@ class _AddInsurancePanelState extends ConsumerState { controller: _sumInsuredController, label: 'Sum Insured', keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: CurrencyFormatter.amountInput, validator: (v) => Validators.optionalNonNegativeDouble( v, fieldName: 'Sum Insured', @@ -1152,6 +1167,7 @@ class _AddInsurancePanelState extends ConsumerState { controller: _annualPremiumController, label: 'Annual Premium', keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: CurrencyFormatter.amountInput, validator: (v) => Validators.optionalNonNegativeDouble( v, fieldName: 'Annual Premium', @@ -1240,7 +1256,7 @@ class _AddInsurancePanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: true, - saveLabel: 'Edit Insurance Policy', + saveLabel: 'Update Insurance Policy', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), @@ -1256,7 +1272,7 @@ class _AddInsurancePanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Edit Insurance Policy', + saveLabel: 'Update Insurance Policy', onSave: _save, ), child: _buildForm(), @@ -1270,7 +1286,7 @@ class _AddInsurancePanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Add Insurance Policy', + saveLabel: 'Save Insurance Policy', onSave: _save, ), child: _buildForm(), diff --git a/lib/modules/auth/presentation/screens/login_screen.dart b/lib/modules/auth/presentation/screens/login_screen.dart index 9681cfb..60d76c2 100644 --- a/lib/modules/auth/presentation/screens/login_screen.dart +++ b/lib/modules/auth/presentation/screens/login_screen.dart @@ -6,7 +6,6 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../../core/config/dev_config.dart'; -import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/enums.dart'; import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/storage_keys.dart'; @@ -355,14 +354,24 @@ class _LoginScreenState extends ConsumerState ); } - Widget _buildLogo(String logoUrl) { + Widget _buildLogo(String logoUrl, LoginColors colors) { return Center( - child: SidebarLogo( - logoUrl: logoUrl, - height: 64, - width: 220, - fit: BoxFit.contain, - showBackground: false, + child: DecoratedBox( + decoration: BoxDecoration( + // Light: blend into the card. Dark: keep white so the logo stays readable. + color: colors.isDark ? Colors.white : colors.cardBackground, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: SidebarLogo( + logoUrl: logoUrl, + height: 64, + width: 220, + fit: BoxFit.contain, + showBackground: false, + ), + ), ), ); } @@ -411,18 +420,6 @@ class _LoginScreenState extends ConsumerState ); } - Widget _versionLabel(LoginColors colors) { - return Text( - '${AppConstants.appName} · v${AppConstants.appVersion}', - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 11, - letterSpacing: 0.3, - color: colors.onSurfaceVariant, - ), - ); - } - Widget _backToSignInButton(LoginColors colors) { return Center( child: TextButton( @@ -477,117 +474,118 @@ class _LoginScreenState extends ConsumerState }) { return Form( key: _loginFormKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildLogo(logoUrl), - const SizedBox(height: 20), - Text( - 'Welcome back', - style: GoogleFonts.manrope( - fontSize: 24, - fontWeight: FontWeight.w800, - letterSpacing: -0.3, - color: colors.headingColor, - ), - ), - const SizedBox(height: 5), - Text( - 'Sign in to continue to your BCPL workspace.', - style: GoogleFonts.inter( - fontSize: 13.5, - color: colors.subtitleColor, - ), - ), - const SizedBox(height: 26), - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.email], - validator: Validators.email, - style: GoogleFonts.inter( - fontSize: 14.5, - color: colors.headingColor, - ), - decoration: _fieldDecoration( - colors: colors, - label: 'Email address', - icon: Icons.mail_outline_rounded, - ), - ), - const SizedBox(height: 18), - TextFormField( - controller: _passwordController, - obscureText: _obscurePassword, - autofillHints: const [AutofillHints.password], - validator: (v) => Validators.required(v, fieldName: 'Password'), - style: GoogleFonts.inter( - fontSize: 14.5, - color: colors.headingColor, - ), - decoration: _fieldDecoration( - colors: colors, - label: 'Password', - icon: Icons.lock_outline_rounded, - suffix: IconButton( - tooltip: _obscurePassword ? 'Show password' : 'Hide password', - icon: Icon( - _obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - color: colors.iconMuted, - size: 18, - ), - onPressed: () => - setState(() => _obscurePassword = !_obscurePassword), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildLogo(logoUrl, colors), + const SizedBox(height: 20), + Text( + 'Welcome', + style: GoogleFonts.manrope( + fontSize: 24, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: colors.headingColor, ), ), - ), - const SizedBox(height: 10), - Row( - children: [ - SizedBox( - height: 34, - width: 34, - child: Checkbox( - value: _rememberMe, - activeColor: colors.primary, - checkColor: colors.onPrimary, - side: BorderSide(color: colors.outline, width: 1.5), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + const SizedBox(height: 5), + Text( + 'Sign in to access your BCPL workspace.', + style: GoogleFonts.inter( + fontSize: 13.5, + color: colors.subtitleColor, + ), + ), + const SizedBox(height: 26), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + validator: Validators.email, + style: GoogleFonts.inter( + fontSize: 14.5, + color: colors.headingColor, + ), + decoration: _fieldDecoration( + colors: colors, + label: 'Email address', + icon: Icons.mail_outline_rounded, + ), + ), + const SizedBox(height: 18), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.password], + validator: (v) => Validators.required(v, fieldName: 'Password'), + style: GoogleFonts.inter( + fontSize: 14.5, + color: colors.headingColor, + ), + decoration: _fieldDecoration( + colors: colors, + label: 'Password', + icon: Icons.lock_outline_rounded, + suffix: IconButton( + tooltip: _obscurePassword ? 'Show password' : 'Hide password', + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: colors.iconMuted, + size: 18, ), - onChanged: (v) => setState(() => _rememberMe = v ?? false), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), ), ), - Text( - 'Remember me', - style: GoogleFonts.inter( - fontSize: 13, - color: colors.labelColor, + ), + const SizedBox(height: 10), + Row( + children: [ + SizedBox( + height: 34, + width: 34, + child: Checkbox( + value: _rememberMe, + activeColor: colors.primary, + checkColor: colors.onPrimary, + side: BorderSide(color: colors.outline, width: 1.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + onChanged: (v) => setState(() => _rememberMe = v ?? false), + ), ), - ), - const Spacer(), - TextButton( - onPressed: _flipToForgot, - style: TextButton.styleFrom( - foregroundColor: colors.linkColor, - padding: const EdgeInsets.symmetric(horizontal: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - 'Forgot password?', + Text( + 'Remember me', style: GoogleFonts.inter( fontSize: 13, - fontWeight: FontWeight.w600, + color: colors.labelColor, ), ), - ), - ], - ), - const SizedBox(height: 14), + const Spacer(), + TextButton( + onPressed: _flipToForgot, + style: TextButton.styleFrom( + foregroundColor: colors.linkColor, + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Forgot password?', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 14), _primaryButtonTheme( colors: colors, child: AppButton( @@ -598,45 +596,44 @@ class _LoginScreenState extends ConsumerState ), const SizedBox(height: 26), _secureAccessFooter(colors), - const SizedBox(height: 22), - _versionLabel(colors), if (DevConfig.screenPreviewEnabled) ...[ - const SizedBox(height: 20), - Divider(color: colors.outlineSoft), - const SizedBox(height: 14), - Text( - 'Login API unavailable? Browse all screens without signing in:', - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 12, - color: colors.subtitleColor, + const SizedBox(height: 20), + Divider(color: colors.outlineSoft), + const SizedBox(height: 14), + Text( + 'Login API unavailable? Browse all screens without signing in:', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + color: colors.subtitleColor, + ), ), - ), - const SizedBox(height: 12), - Theme( - data: Theme.of(context).copyWith( - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - foregroundColor: colors.primary, - side: BorderSide(color: colors.outlineSoft), - minimumSize: const Size(double.infinity, 46), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + const SizedBox(height: 12), + Theme( + data: Theme.of(context).copyWith( + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: colors.primary, + side: BorderSide(color: colors.outlineSoft), + minimumSize: const Size(double.infinity, 46), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), ), ), + child: AppButton( + label: 'Explore All Screens', + isOutlined: true, + onPressed: () { + ref.read(authStateProvider.notifier).loginAsDemo(); + context.go(RouteConstants.screenGallery); + }, + ), ), - child: AppButton( - label: 'Explore All Screens', - isOutlined: true, - onPressed: () { - ref.read(authStateProvider.notifier).loginAsDemo(); - context.go(RouteConstants.screenGallery); - }, - ), - ), + ], ], - ], + ), ), ); } @@ -652,7 +649,7 @@ class _LoginScreenState extends ConsumerState mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildLogo(logoUrl), + _buildLogo(logoUrl, colors), const SizedBox(height: 20), Text( 'Forgot Password', @@ -724,8 +721,6 @@ class _LoginScreenState extends ConsumerState _backToSignInButton(colors), const SizedBox(height: 18), _secureAccessFooter(colors), - const SizedBox(height: 22), - _versionLabel(colors), ], ), ); @@ -742,7 +737,7 @@ class _LoginScreenState extends ConsumerState mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildLogo(logoUrl), + _buildLogo(logoUrl, colors), const SizedBox(height: 20), Text( 'Reset Password', @@ -801,7 +796,7 @@ class _LoginScreenState extends ConsumerState if (v != _newPasswordController.text) { return 'Passwords do not match'; } - return Validators.required(v, fieldName: 'Confirm password'); + return Validators.password(v); }, style: GoogleFonts.inter( fontSize: 14.5, @@ -864,8 +859,6 @@ class _LoginScreenState extends ConsumerState _backToSignInButton(colors), const SizedBox(height: 18), _secureAccessFooter(colors), - const SizedBox(height: 22), - _versionLabel(colors), ], ), ); @@ -944,6 +937,14 @@ class _LoginScreenState extends ConsumerState ); if (_cardHeight == null) return flipped; + + // Lock height only while flipping / on the back face so the two + // faces match. Keep the front face unconstrained so validation + // errors can expand without overflowing. + final lockHeight = + _flipController.isAnimating || !_flipController.isDismissed; + if (!lockHeight) return flipped; + return SizedBox( height: _cardHeight, width: double.infinity, diff --git a/lib/modules/auth/presentation/screens/reset_password_screen.dart b/lib/modules/auth/presentation/screens/reset_password_screen.dart index f450ce7..7ad1906 100644 --- a/lib/modules/auth/presentation/screens/reset_password_screen.dart +++ b/lib/modules/auth/presentation/screens/reset_password_screen.dart @@ -52,7 +52,7 @@ class _ResetPasswordScreenState extends State { obscureText: true, validator: (v) { if (v != _passwordController.text) return 'Passwords do not match'; - return Validators.required(v, fieldName: 'Confirm Password'); + return Validators.password(v); }, ), const SizedBox(height: 24), diff --git a/lib/modules/auth/presentation/widgets/login_hero_panel.dart b/lib/modules/auth/presentation/widgets/login_hero_panel.dart index 0eb91a2..db7a898 100644 --- a/lib/modules/auth/presentation/widgets/login_hero_panel.dart +++ b/lib/modules/auth/presentation/widgets/login_hero_panel.dart @@ -84,50 +84,25 @@ class _BrandMark extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 40, - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.panelText.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colors.panelText.withValues(alpha: 0.18), - ), - ), - child: Text( - 'BC', - style: GoogleFonts.manrope( - fontWeight: FontWeight.w800, - fontSize: 16, - color: colors.panelText, - ), + Text( + 'BCPL', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w800, + fontSize: 20, + letterSpacing: 0.5, + color: colors.panelText, ), ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'BCPL', - style: GoogleFonts.manrope( - fontWeight: FontWeight.w800, - fontSize: 20, - letterSpacing: 0.5, - color: colors.panelText, - ), - ), - Text( - 'BHARAT ERP', - style: GoogleFonts.inter( - fontSize: 11, - letterSpacing: 1.5, - color: colors.panelTextDim, - ), - ), - ], + Text( + 'BHARAT ERP', + style: GoogleFonts.inter( + fontSize: 11, + letterSpacing: 1.5, + color: colors.panelTextDim, + ), ), ], ); @@ -142,74 +117,65 @@ class _FeatureRow extends StatelessWidget { @override Widget build(BuildContext context) { final items = [ - ( - Icons.layers_outlined, - 'Unified data', - 'One source of truth across every module', - ), - ( - Icons.bolt_outlined, - 'Real-time sync', - 'Every team sees the same live numbers', - ), - ( - Icons.bar_chart_rounded, - 'Clear reporting', - 'Dashboards built for daily decisions', - ), + (Icons.layers_outlined, 'Unified data'), + (Icons.bolt_outlined, 'Real-time sync'), + (Icons.bar_chart_rounded, 'Clear reporting'), ]; - return Wrap( - spacing: 28, - runSpacing: 16, + return Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - for (final item in items) - SizedBox( - width: 160, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 30, - height: 30, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.panelText.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(9), - border: Border.all( - color: colors.panelText.withValues(alpha: 0.16), - ), - ), - child: Icon(item.$1, size: 15, color: colors.panelText), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.$2, - style: GoogleFonts.inter( - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: colors.panelText, - ), - ), - const SizedBox(height: 2), - Text( - item.$3, - style: GoogleFonts.inter( - fontSize: 11.5, - height: 1.4, - color: colors.panelTextDim, - ), - ), - ], - ), - ), - ], + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) const SizedBox(width: 28), + _FeatureItem( + colors: colors, + icon: items[i].$1, + title: items[i].$2, + ), + ], + ], + ); + } +} + +class _FeatureItem extends StatelessWidget { + const _FeatureItem({ + required this.colors, + required this.icon, + required this.title, + }); + + final LoginColors colors; + final IconData icon; + final String title; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.panelText.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: colors.panelText.withValues(alpha: 0.16), ), ), + child: Icon(icon, size: 15, color: colors.panelText), + ), + const SizedBox(width: 10), + Text( + title, + style: GoogleFonts.inter( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: colors.panelText, + ), + ), ], ); } diff --git a/lib/modules/branch/presentation/screens/branch_form_screen.dart b/lib/modules/branch/presentation/screens/branch_form_screen.dart index 3c6e0e4..43000d5 100644 --- a/lib/modules/branch/presentation/screens/branch_form_screen.dart +++ b/lib/modules/branch/presentation/screens/branch_form_screen.dart @@ -61,7 +61,7 @@ class _BranchFormScreenState extends State { AppTextField(controller: _managerController, label: 'Manager'), const SizedBox(height: 24), AppButton( - label: isEditing ? 'Update Branch' : 'Create Branch', + label: isEditing ? 'Update Branch' : 'Save Branch', onPressed: () { if (_formKey.currentState!.validate()) Navigator.of(context).pop(); }, diff --git a/lib/modules/company/presentation/screens/company_form_screen.dart b/lib/modules/company/presentation/screens/company_form_screen.dart index 6687291..c923813 100644 --- a/lib/modules/company/presentation/screens/company_form_screen.dart +++ b/lib/modules/company/presentation/screens/company_form_screen.dart @@ -89,7 +89,7 @@ class _CompanyFormScreenState extends State { ), const SizedBox(height: 24), AppButton( - label: isEditing ? 'Update Company' : 'Create Company', + label: isEditing ? 'Update Company' : 'Save Company', onPressed: () { if (_formKey.currentState!.validate()) { Navigator.of(context).pop(); diff --git a/lib/modules/dashboard/presentation/screens/dashboard_screen.dart b/lib/modules/dashboard/presentation/screens/dashboard_screen.dart index 4f56706..831a0b1 100644 --- a/lib/modules/dashboard/presentation/screens/dashboard_screen.dart +++ b/lib/modules/dashboard/presentation/screens/dashboard_screen.dart @@ -1,10 +1,7 @@ -import '../../../../shared/widgets/app_card.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import '../../../../core/constants/route_constants.dart'; +import '../../../../core/constants/app_constants.dart'; import '../../../../core/utils/responsive_utils.dart'; -import '../../../../shared/widgets/kpi_card.dart'; import '../../../../shared/widgets/page_header.dart'; class DashboardScreen extends StatelessWidget { @@ -12,83 +9,52 @@ class DashboardScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return SingleChildScrollView( + final theme = Theme.of(context); + + return Padding( padding: const EdgeInsets.all(24), child: Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: context.contentMaxWidth), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const PageHeader( title: 'Dashboard', - subtitle: 'Asset management overview', + subtitle: 'Overview', ), - LayoutBuilder( - builder: (context, constraints) { - final crossAxisCount = constraints.maxWidth > 900 ? 3 : (constraints.maxWidth > 600 ? 2 : 1); - return GridView.count( - crossAxisCount: crossAxisCount, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 16, - crossAxisSpacing: 16, - childAspectRatio: 1.8, + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - KpiCard( - title: 'Total Assets', - value: '—', - icon: Icons.inventory_2_outlined, - onTap: () => context.go(RouteConstants.assets), + Image.asset( + AppConstants.defaultLogoAsset, + height: 72, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Icon( + Icons.dashboard_outlined, + size: 72, + color: theme.colorScheme.primary, + ), ), - KpiCard( - title: 'Allocated', - value: '—', - icon: Icons.assignment_ind_outlined, - color: Theme.of(context).colorScheme.primary, + const SizedBox(height: 24), + Text( + 'Dashboard coming soon', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), - KpiCard( - title: 'Available', - value: '—', - icon: Icons.check_circle_outline, - color: Theme.of(context).colorScheme.secondary, - ), - KpiCard( - title: 'Under Maintenance', - value: '—', - icon: Icons.build_outlined, - color: Colors.orange, - ), - KpiCard( - title: 'Disposed', - value: '—', - icon: Icons.delete_outline, - color: Colors.red, - ), - KpiCard( - title: 'Warranty Expiring', - value: '—', - icon: Icons.warning_amber_outlined, - color: Colors.amber, + const SizedBox(height: 8), + Text( + 'This space is empty for now. Insights and charts will appear here later.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ], - ); - }, - ), - const SizedBox(height: 32), - Text('Charts', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 16), - AppCard( - child: Padding( - padding: const EdgeInsets.all(48), - child: Center( - child: Text( - 'Charts will load from API\n(Assets by Category, Branch, Allocation Trend)', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), ), ), ), diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index a795fc7..ae35512 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -18,13 +18,14 @@ import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; +import '../../../../shared/widgets/app_sticky_form_layout.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; +import '../../../../shared/widgets/app_toast.dart'; import '../providers/grn_lookups_provider.dart'; import '../providers/grn_provider.dart'; import '../widgets/grn_line_items_editor.dart'; import '../widgets/grn_status_chip.dart'; -import '../../../../shared/widgets/app_toast.dart'; class GrnFormScreen extends ConsumerStatefulWidget { const GrnFormScreen({super.key, this.grnId}); @@ -406,16 +407,16 @@ class _GrnFormScreenState extends ConsumerState { .toList(); final theme = Theme.of(context); - return SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), - child: Form( - key: _formKey, - child: Column( + return Form( + key: _formKey, + child: AppStickyFormLayout( + scrollController: _scrollController, + headerPadding: const EdgeInsets.fromLTRB(24, 12, 24, 12), + bodyPadding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + header: _buildHeader(existing), + body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildHeader(existing), - const SizedBox(height: 16), _SectionCard( title: 'RECEIPT DETAILS', child: Column( @@ -686,7 +687,7 @@ class _GrnFormScreenState extends ConsumerState { ); return Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: EdgeInsets.zero, child: LayoutBuilder( builder: (context, constraints) { final stack = constraints.maxWidth < 720; diff --git a/lib/modules/master_data/presentation/providers/master_consumer_invalidation.dart b/lib/modules/master_data/presentation/providers/master_consumer_invalidation.dart new file mode 100644 index 0000000..edf3d34 --- /dev/null +++ b/lib/modules/master_data/presentation/providers/master_consumer_invalidation.dart @@ -0,0 +1,48 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../assets/presentation/providers/asset_categories_provider.dart'; +import '../../../assets/presentation/providers/asset_form_lookups_provider.dart'; +import '../../../grn/presentation/providers/grn_lookups_provider.dart'; +import '../../../purchase_orders/presentation/providers/purchase_order_lookups_provider.dart'; +import '../../../rbac/presentation/providers/add_user_form_provider.dart'; +import '../../../vendors/presentation/providers/vendor_lookups_provider.dart'; + +/// Refresh form/list dropdown caches that read this master outside Master Data. +/// +/// Pass [Ref.invalidate] or [WidgetRef.invalidate]. Callers should also refresh +/// [masterListProvider] for the same [masterId] when needed. +void invalidateMasterConsumerLookups( + void Function(ProviderOrFamily provider) invalidate, + String masterId, +) { + switch (masterId) { + case 'item_categories': + invalidate(itemCategoriesFormProvider); + invalidate(itemCategoriesProvider); + invalidate(itemSubcategoriesProvider); + case 'item_subcategories': + invalidate(itemSubcategoriesProvider); + case 'locations': + invalidate(assetFormLookupsProvider); + invalidate(assetListFilterLookupsProvider); + invalidate(purchaseOrderLookupsProvider); + invalidate(grnLookupsProvider); + invalidate(addUserFormProvider); + case 'departments': + invalidate(assetFormLookupsProvider); + invalidate(addUserFormProvider); + case 'designations': + invalidate(addUserFormProvider); + case 'uom': + case 'items': + case 'hsn_codes': + case 'gst_rates': + case 'delivery_terms': + invalidate(purchaseOrderLookupsProvider); + case 'payment_terms': + invalidate(purchaseOrderLookupsProvider); + invalidate(vendorPaymentTermsProvider); + case 'terms_notes': + invalidate(poDefaultTermsNotesProvider); + } +} diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart index 993f0f4..03c89ae 100644 --- a/lib/modules/master_data/presentation/providers/master_provider.dart +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -9,6 +9,7 @@ import '../../../assets/data/repositories/asset_repository_impl.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../data/repositories/master_repository_impl.dart'; import '../../domain/entities/master_definition.dart'; +import 'master_consumer_invalidation.dart'; class MasterListState { const MasterListState({ @@ -248,6 +249,7 @@ class MasterListNotifier extends FamilyAsyncNotifier { } await refresh(); + invalidateMasterConsumerLookups(ref.invalidate, _definition.id); return true; } @@ -644,6 +646,9 @@ class MasterFormNotifier extends FamilyAsyncNotifier { width: 560, ); if (saved != null && mounted) { - ref.invalidate(masterListProvider(widget.masterId)); + invalidateMasterConsumerLookups(ref.invalidate, widget.masterId); showAppToastFromSnackBar(context, SnackBar( content: Text( @@ -155,6 +156,10 @@ class _MasterListScreenState extends ConsumerState { await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id); if (!mounted) return; + if (success) { + invalidateMasterConsumerLookups(ref.invalidate, widget.masterId); + } + showAppToastFromSnackBar(context, SnackBar( content: Text( 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 fbeb317..0e6b171 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -489,7 +489,8 @@ class _MasterFormPanelState extends ConsumerState { ), const SizedBox(width: 12), AppButton( - label: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}', + label: + widget.isEditing ? 'Update ${def.title}' : 'Save ${def.title}', expand: false, icon: Icons.check, isLoading: isSubmitting, 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 98b5788..27531d2 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 @@ -497,7 +497,7 @@ class _MasterInlineCreateFormState ), const SizedBox(width: 8), AppButton( - label: 'Add ${def.title}', + label: 'Save ${def.title}', expand: false, icon: Icons.check, isLoading: isSubmitting, 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 5bf08bf..96dfbd8 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 @@ -134,11 +134,11 @@ class PurchaseOrderRemoteDataSource { Future rejectPurchaseOrder( String id, { - required String remarks, + required String rejectReason, }) async { final response = await dio.post( ApiEndpoints.purchaseOrderReject(id), - data: {'remarks': remarks}, + data: {'reject_reason': rejectReason}, ); return PurchaseOrderModel.fromJson( response.data['data'] as Map, diff --git a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart index 7b08e6c..dd076b4 100644 --- a/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart +++ b/lib/modules/purchase_orders/data/repositories/purchase_order_repository_impl.dart @@ -98,10 +98,10 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository { @override Future> rejectPurchaseOrder( String id, { - required String remarks, + required String rejectReason, }) { return safeApiCall( - () => dataSource.rejectPurchaseOrder(id, remarks: remarks), + () => dataSource.rejectPurchaseOrder(id, rejectReason: rejectReason), ); } diff --git a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart index 7b6d2f2..24b537e 100644 --- a/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart +++ b/lib/modules/purchase_orders/domain/repositories/purchase_order_repository.dart @@ -27,7 +27,7 @@ abstract class PurchaseOrderRepository { Future> approvePurchaseOrder(String id, {String? remarks}); Future> rejectPurchaseOrder( String id, { - required String remarks, + required String rejectReason, }); Future> amendPurchaseOrder( String id, { 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 6117198..13b2e4e 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_orders_provider.dart @@ -395,9 +395,12 @@ class PurchaseOrderDetailNotifier return result.data!; } - Future reject({required String remarks}) async { + Future reject({required String rejectReason}) async { final repository = ref.read(purchaseOrderRepositoryProvider); - final result = await repository.rejectPurchaseOrder(arg, remarks: remarks); + final result = await repository.rejectPurchaseOrder( + arg, + rejectReason: rejectReason, + ); if (result.failure != null) throw result.failure!; state = AsyncData(result.data!); ref.invalidate(purchaseOrdersListProvider); diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart index 1c0c857..93ed66b 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart @@ -109,6 +109,10 @@ class _PurchaseOrderDetailScreenState onCancel: () => _cancel(order), onDelete: _delete, ), + if (order.rejectReasonForDisplay != null) ...[ + const SizedBox(height: 12), + _RejectReasonBanner(reason: order.rejectReasonForDisplay!), + ], const SizedBox(height: 16), _OrderDetailsCard(order: order, lookups: lookups), const SizedBox(height: 16), @@ -179,7 +183,9 @@ class _PurchaseOrderDetailScreenState () => ref .read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier) .submit(), - 'Purchase order submitted for approval', + order.isRejected + ? 'Purchase order resubmitted for approval' + : 'Purchase order submitted for approval', ); } @@ -224,14 +230,15 @@ class _PurchaseOrderDetailScreenState } Future _reject(PurchaseOrderModel order) async { - final remarksController = TextEditingController(); + final reasonController = TextEditingController(); final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Reject Purchase Order'), content: AppTextField( - controller: remarksController, - label: 'Remarks *', + controller: reasonController, + label: 'Reject reason *', + hint: 'Why is this purchase order being rejected?', maxLines: 3, ), actions: [ @@ -247,18 +254,19 @@ class _PurchaseOrderDetailScreenState ), ); if (confirmed != true || !mounted) return; - final remarks = remarksController.text.trim(); - remarksController.dispose(); - if (remarks.isEmpty) { - showAppToastFromSnackBar(context, - const SnackBar(content: Text('Rejection remarks are required')), + final rejectReason = reasonController.text.trim(); + reasonController.dispose(); + if (rejectReason.isEmpty) { + showAppToastFromSnackBar( + context, + const SnackBar(content: Text('Reject reason is required')), ); return; } await _runWorkflow( () => ref .read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier) - .reject(remarks: remarks), + .reject(rejectReason: rejectReason), 'Purchase order rejected', ); } @@ -425,6 +433,56 @@ String _hsnLabel( return _lookupName(lookups?.hsnCodes, item.hsnCodeId); } +class _RejectReasonBanner extends StatelessWidget { + const _RejectReasonBanner({required this.reason}); + + final String reason; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.35), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: theme.colorScheme.error, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Reject reason', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + reason, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + class _DetailHeader extends StatelessWidget { const _DetailHeader({ required this.order, @@ -495,7 +553,7 @@ class _DetailHeader extends StatelessWidget { ), if (canEdit && order.canSubmit) _HeaderActionButton( - label: 'Submit', + label: order.isRejected ? 'Resubmit' : 'Submit', icon: Icons.send_outlined, filled: true, onPressed: isWorking ? null : onSubmit, @@ -556,6 +614,41 @@ class _DetailHeader extends StatelessWidget { ), const SizedBox(width: 10), PoStatusChip(status: order.status, compact: true), + if (order.isRejected) ...[ + const SizedBox(width: 4), + Tooltip( + richMessage: TextSpan( + children: [ + TextSpan( + text: 'Reject reason\n', + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onInverseSurface, + ), + ), + TextSpan( + text: order.rejectReasonForDisplay ?? + 'No reject reason provided', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onInverseSurface, + ), + ), + ], + ), + waitDuration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + margin: const EdgeInsets.only(top: 6), + decoration: BoxDecoration( + color: theme.colorScheme.inverseSurface, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.info_outline, + size: 18, + color: theme.colorScheme.error, + ), + ), + ], if (order.revisionNo != null && order.revisionNo! > 0) ...[ const SizedBox(width: 8), PoRevisionChip(revisionNo: order.revisionNo!, compact: true), 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 e5557f0..9b9492e 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 @@ -21,6 +21,7 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; +import '../../../../shared/widgets/app_sticky_form_layout.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'; @@ -439,20 +440,25 @@ class _PurchaseOrderFormScreenState extends ConsumerState order.status, - cellBuilder: (_, order) => PoStatusChip( - status: order.status, - compact: true, - forTable: true, - ), + cellBuilder: (_, order) { + final chip = PoStatusChip( + status: order.status, + compact: true, + forTable: true, + ); + final reason = order.rejectReasonForDisplay; + if (reason == null) return chip; + return Tooltip( + message: 'Reject reason: $reason', + child: chip, + ); + }, ), AppDataColumn( label: 'Actions', 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 c6b4172..c6fa2e2 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -6,6 +6,7 @@ import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/responsive_utils.dart'; +import '../../../../core/utils/validators.dart'; import '../../../../shared/models/permission_matrix_models.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/utils/file_download_helper.dart'; @@ -26,6 +27,7 @@ import '../providers/add_user_form_provider.dart'; import '../providers/role_form_provider.dart'; import '../providers/rbac_provider.dart'; import '../../../../shared/widgets/app_hover_effect.dart'; +import '../../../../shared/widgets/app_segmented_tab_bar.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../roles/presentation/providers/roles_provider.dart'; import '../widgets/add_user_panel.dart'; @@ -395,37 +397,45 @@ class _TabBar extends ConsumerWidget { final canExport = ref.can('users', PermissionAction.export); final isExporting = usersState?.isExporting ?? false; + final tabEntries = <(RbacTab, AppSegmentedTab)>[ + if (canViewUsers) + ( + RbacTab.users, + AppSegmentedTab( + label: 'Users ($userCount)', + icon: Icons.people_outline, + ), + ), + if (canViewRoles) + ( + RbacTab.roles, + AppSegmentedTab( + label: 'Roles ($roleCount)', + icon: Icons.shield_outlined, + ), + ), + if (canEditRoles) + ( + RbacTab.permissions, + const AppSegmentedTab( + label: 'Permission Matrix', + icon: Icons.vpn_key_outlined, + ), + ), + ]; + + final selectedIndex = tabEntries + .indexWhere((entry) => entry.$1 == state.selectedTab) + .clamp(0, tabEntries.isEmpty ? 0 : tabEntries.length - 1); + return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - if (canViewUsers) - _TabButton( - label: 'Users ($userCount)', - icon: Icons.people_outline, - selected: isUsersTab, - onTap: () => onSelectTab(RbacTab.users), - ), - if (canViewRoles) - _TabButton( - label: 'Roles ($roleCount)', - icon: Icons.shield_outlined, - selected: state.selectedTab == RbacTab.roles, - onTap: () => onSelectTab(RbacTab.roles), - ), - if (canEditRoles) - _TabButton( - label: 'Permission Matrix', - icon: Icons.vpn_key_outlined, - selected: state.selectedTab == RbacTab.permissions, - onTap: () => onSelectTab(RbacTab.permissions), - ), - ], - ), + child: AppSegmentedTabBar( + selectedIndex: selectedIndex, + onChanged: (index) => onSelectTab(tabEntries[index].$1), + tabs: [for (final entry in tabEntries) entry.$2], ), ), if (isUsersTab) ...[ @@ -482,59 +492,6 @@ Future _exportUsersFromTabBar(BuildContext context, WidgetRef ref) async { ); } -class _TabButton extends StatelessWidget { - const _TabButton({ - required this.label, - required this.icon, - required this.selected, - required this.onTap, - }); - - final String label; - final IconData icon; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final primary = Theme.of(context).colorScheme.primary; - return InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: selected ? primary : Colors.transparent, - width: 2, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 18, - color: selected ? primary : Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: selected - ? primary - : Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - ), - ), - ], - ), - ), - ); - } -} - class _UsersTab extends ConsumerStatefulWidget { const _UsersTab({ required this.filtersExpanded, @@ -639,17 +596,22 @@ class _UsersTabState extends ConsumerState<_UsersTab> { Future _resetPassword(ManagedUserModel user) async { final controller = TextEditingController(); + final formKey = GlobalKey(); final password = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Reset Password'), - content: TextField( - controller: controller, - obscureText: true, - autofocus: true, - decoration: const InputDecoration( - labelText: 'New Temporary Password', - hintText: 'Min. 8 characters', + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + obscureText: true, + autofocus: true, + validator: Validators.password, + decoration: const InputDecoration( + labelText: 'New Temporary Password', + hintText: '8+ chars, upper, lower, digit, special', + ), ), ), actions: [ @@ -659,9 +621,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> { ), TextButton( onPressed: () { - final value = controller.text.trim(); - if (value.length < 8) return; - Navigator.of(dialogContext).pop(value); + if (!(formKey.currentState?.validate() ?? false)) return; + Navigator.of(dialogContext).pop(controller.text.trim()); }, child: const Text('Reset'), ), diff --git a/lib/modules/rbac/presentation/widgets/add_user_panel.dart b/lib/modules/rbac/presentation/widgets/add_user_panel.dart index e2c6eb2..f08c3fe 100644 --- a/lib/modules/rbac/presentation/widgets/add_user_panel.dart +++ b/lib/modules/rbac/presentation/widgets/add_user_panel.dart @@ -41,6 +41,7 @@ class _AddUserPanelState extends ConsumerState { String? _selectedDesignationId; String? _selectedReportingToId; bool _prefilled = false; + bool _obscurePassword = true; static const _statusOptions = [ AppDropdownOption(value: 'Active', label: 'Active'), @@ -243,7 +244,7 @@ class _AddUserPanelState extends ConsumerState { ), const SizedBox(width: 12), AppButton( - label: widget.isEditing ? 'Update user' : 'Save user', + label: widget.isEditing ? 'Update User' : 'Save User', expand: false, icon: Icons.check, isLoading: isSubmitting, @@ -384,11 +385,25 @@ class _AddUserPanelState extends ConsumerState { label: widget.isEditing ? 'New Password' : 'Temporary Password *', - hint: 'Min. 8 characters', - obscureText: true, + hint: '8+ chars, upper, lower, digit, special', + obscureText: _obscurePassword, + suffixIcon: IconButton( + tooltip: _obscurePassword + ? 'Show password' + : 'Hide password', + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 20, + ), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + ), validator: widget.isEditing - ? null - : (v) => Validators.required(v, fieldName: 'Password'), + ? Validators.optionalPassword + : Validators.password, ), const SizedBox(height: 4), Text( diff --git a/lib/modules/rbac/presentation/widgets/create_role_panel.dart b/lib/modules/rbac/presentation/widgets/create_role_panel.dart index 03f66f0..816fb99 100644 --- a/lib/modules/rbac/presentation/widgets/create_role_panel.dart +++ b/lib/modules/rbac/presentation/widgets/create_role_panel.dart @@ -113,7 +113,7 @@ class _RoleFormPanelState extends ConsumerState { const SizedBox(width: 12), Expanded( child: AppButton( - label: widget.isEditing ? 'Update role' : 'Create role', + label: widget.isEditing ? 'Update Role' : 'Save Role', expand: true, isLoading: isSubmitting, onPressed: isSubmitting ? null : _save, diff --git a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart index 37c2700..3d4c074 100644 --- a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart +++ b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart @@ -605,6 +605,7 @@ class _ScrollArrowButton extends StatelessWidget { child: IconButton( padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, + tooltip: icon == Icons.chevron_left ? 'Scroll left' : 'Scroll right', onPressed: enabled ? onPressed : null, icon: Icon( icon, diff --git a/lib/modules/users/presentation/screens/user_form_screen.dart b/lib/modules/users/presentation/screens/user_form_screen.dart index 80e1aa3..b878fbd 100644 --- a/lib/modules/users/presentation/screens/user_form_screen.dart +++ b/lib/modules/users/presentation/screens/user_form_screen.dart @@ -272,7 +272,7 @@ class _UserFormScreenState extends ConsumerState { children: [ Expanded( child: AppButton( - label: isEditing ? 'Update User' : 'Create User', + label: isEditing ? 'Update User' : 'Save User', isLoading: _isSubmitting, onPressed: _submit, ), diff --git a/lib/modules/users/presentation/screens/users_roles_hub_screen.dart b/lib/modules/users/presentation/screens/users_roles_hub_screen.dart index 54bcea6..4606e9c 100644 --- a/lib/modules/users/presentation/screens/users_roles_hub_screen.dart +++ b/lib/modules/users/presentation/screens/users_roles_hub_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../core/constants/route_constants.dart'; +import '../../../../shared/widgets/app_segmented_tab_bar.dart'; import '../../../roles/presentation/screens/role_list_screen.dart'; import 'user_list_screen.dart'; @@ -33,13 +34,19 @@ class _UsersRolesHubScreenState extends State Widget build(BuildContext context) { return Column( children: [ - Material( - color: Theme.of(context).colorScheme.surface, - child: TabBar( + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: AppSegmentedTabBar( controller: _tabController, tabs: const [ - Tab(text: 'Users', icon: Icon(Icons.people_outline)), - Tab(text: 'Roles', icon: Icon(Icons.security_outlined)), + AppSegmentedTab( + label: 'Users', + icon: Icons.people_outline, + ), + AppSegmentedTab( + label: 'Roles', + icon: Icons.security_outlined, + ), ], ), ), diff --git a/lib/modules/vendors/presentation/providers/vendor_lookups_provider.dart b/lib/modules/vendors/presentation/providers/vendor_lookups_provider.dart new file mode 100644 index 0000000..db0df4f --- /dev/null +++ b/lib/modules/vendors/presentation/providers/vendor_lookups_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../shared/models/user_management_models.dart'; +import '../../../masters/data/datasources/master_remote_data_source.dart'; +import '../../data/repositories/vendor_repository_impl.dart'; + +final vendorPaymentTermsProvider = FutureProvider.autoDispose< + ({ + List options, + Map creditDaysById, + })>((ref) async { + final dataSource = ref.watch(masterRemoteDataSourceProvider); + return dataSource.listPaymentTermsWithCreditDays(); +}); + +final vendorGstTreatmentsProvider = + FutureProvider.autoDispose>((ref) async { + final dataSource = ref.watch(vendorRemoteDataSourceProvider); + return dataSource.getGstTreatments(); +}); + +final vendorSourceOfSupplyProvider = + FutureProvider.autoDispose>((ref) async { + final dataSource = ref.watch(vendorRemoteDataSourceProvider); + return dataSource.getSourceOfSupply(); +}); diff --git a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart index d170011..96e5aa6 100644 --- a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart @@ -14,6 +14,7 @@ import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; +import '../../../../shared/widgets/app_segmented_tab_bar.dart'; import '../providers/vendors_provider.dart'; import '../widgets/vendor_form_panel.dart'; import '../widgets/vendor_sub_resource_panels.dart'; @@ -116,13 +117,25 @@ class _VendorDetailScreenState extends ConsumerState ], ), const SizedBox(height: 16), - TabBar( + AppSegmentedTabBar( controller: _tabController, tabs: const [ - Tab(text: 'Overview'), - Tab(text: 'Addresses'), - Tab(text: 'Contacts'), - Tab(text: 'Bank Details'), + AppSegmentedTab( + label: 'Overview', + icon: Icons.dashboard_outlined, + ), + AppSegmentedTab( + label: 'Addresses', + icon: Icons.location_on_outlined, + ), + AppSegmentedTab( + label: 'Contacts', + icon: Icons.contacts_outlined, + ), + AppSegmentedTab( + label: 'Bank Details', + icon: Icons.account_balance_outlined, + ), ], ), const SizedBox(height: 16), @@ -803,11 +816,13 @@ class _VendorAddressCard extends StatelessWidget { const Spacer(), if (canEdit) ...[ IconButton( + tooltip: 'Edit', icon: const Icon(Icons.edit_outlined, size: 18), onPressed: onEdit, visualDensity: VisualDensity.compact, ), IconButton( + tooltip: 'Delete', icon: const Icon(Icons.delete_outline, size: 18), onPressed: onDelete, visualDensity: VisualDensity.compact, @@ -968,11 +983,13 @@ class _VendorContactCard extends StatelessWidget { ], if (canEdit) ...[ IconButton( + tooltip: 'Edit', icon: const Icon(Icons.edit_outlined, size: 18), onPressed: onEdit, visualDensity: VisualDensity.compact, ), IconButton( + tooltip: 'Delete', icon: const Icon(Icons.delete_outline, size: 18), onPressed: onDelete, visualDensity: VisualDensity.compact, @@ -1146,11 +1163,13 @@ class _VendorBankDetailCard extends StatelessWidget { ], if (canEdit) ...[ IconButton( + tooltip: 'Edit', icon: const Icon(Icons.edit_outlined, size: 18), onPressed: onEdit, visualDensity: VisualDensity.compact, ), IconButton( + tooltip: 'Delete', icon: const Icon(Icons.delete_outline, size: 18), onPressed: onDelete, visualDensity: VisualDensity.compact, diff --git a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart index ac9a45c..3a90aef 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart @@ -13,9 +13,8 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; -import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../master_data/presentation/widgets/master_quick_add.dart'; -import '../../data/repositories/vendor_repository_impl.dart'; +import '../providers/vendor_lookups_provider.dart'; import '../providers/vendors_provider.dart'; import '../../../../shared/widgets/app_toast.dart'; @@ -396,24 +395,3 @@ class _VendorFormPanelState extends ConsumerState { ); } } - -final vendorPaymentTermsProvider = FutureProvider< - ({ - List options, - Map creditDaysById, - })>((ref) async { - final dataSource = ref.watch(masterRemoteDataSourceProvider); - return dataSource.listPaymentTermsWithCreditDays(); -}); - -final vendorGstTreatmentsProvider = - FutureProvider>((ref) async { - final dataSource = ref.watch(vendorRemoteDataSourceProvider); - return dataSource.getGstTreatments(); -}); - -final vendorSourceOfSupplyProvider = - FutureProvider>((ref) async { - final dataSource = ref.watch(vendorRemoteDataSourceProvider); - return dataSource.getSourceOfSupply(); -}); diff --git a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart index 1eb42b3..aac75b9 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart @@ -158,7 +158,7 @@ class _VendorAddressPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: widget.isEditing ? 'Update address' : 'Save address', + saveLabel: widget.isEditing ? 'Update Address' : 'Save Address', onSave: _save, ), child: Form( @@ -299,7 +299,7 @@ class _VendorContactPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: widget.isEditing ? 'Update contact' : 'Save contact', + saveLabel: widget.isEditing ? 'Update Contact' : 'Save Contact', onSave: _save, ), child: Form( @@ -451,7 +451,7 @@ class _VendorBankDetailPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: widget.isEditing ? 'Update bank detail' : 'Save bank detail', + saveLabel: widget.isEditing ? 'Update Bank Detail' : 'Save Bank Detail', onSave: _save, ), child: Form( diff --git a/lib/shared/models/purchase_order_model.dart b/lib/shared/models/purchase_order_model.dart index cd9fd2b..721e5b2 100644 --- a/lib/shared/models/purchase_order_model.dart +++ b/lib/shared/models/purchase_order_model.dart @@ -171,6 +171,20 @@ Object? _readTaxTotal(Map json, String key) => Object? _readSubTotal(Map json, String key) => _doubleFromJsonNullable(json['sub_total'] ?? json['taxable_amount']); +Object? _readRejectReason(Map json, String key) { + for (final candidate in [ + json['reject_reason'], + json['rejection_remarks'], + json['reject_remarks'], + json['rejection_reason'], + ]) { + if (candidate == null) continue; + final text = candidate.toString().trim(); + if (text.isNotEmpty) return text; + } + return null; +} + @freezed class PurchaseOrderModel with _$PurchaseOrderModel { const PurchaseOrderModel._(); @@ -209,6 +223,8 @@ class PurchaseOrderModel with _$PurchaseOrderModel { double? totalAmount, @JsonKey(name: 'terms_and_conditions') String? termsAndConditions, String? remarks, + @JsonKey(name: 'reject_reason', readValue: _readRejectReason) + String? rejectReason, @JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) int? revisionNo, @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt, @JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt, @@ -218,18 +234,21 @@ class PurchaseOrderModel with _$PurchaseOrderModel { factory PurchaseOrderModel.fromJson(Map json) => _$PurchaseOrderModelFromJson(json); - bool get canEdit => status.toUpperCase() == 'DRAFT'; + bool get canEdit { + final s = status.toUpperCase(); + return s == 'DRAFT' || s == 'REJECTED'; + } - bool get canDelete => canEdit; + bool get canDelete => status.toUpperCase() == 'DRAFT'; - bool get canSubmit => status.toUpperCase() == 'DRAFT'; + bool get canSubmit { + final s = status.toUpperCase(); + return s == 'DRAFT' || s == 'REJECTED'; + } bool get canApprove { final s = status.toUpperCase(); - return s == 'SUBMITTED' || - s == 'PENDING_APPROVAL' || - s == 'PENDING' || - s == 'REJECTED'; + return s == 'PENDING_APPROVAL' || s == 'SUBMITTED' || s == 'PENDING'; } /// Notify approvers via `POST /notifications/trigger` (PO_SUBMIT_APPROVAL). @@ -243,6 +262,16 @@ class PurchaseOrderModel with _$PurchaseOrderModel { final s = status.toUpperCase(); return s != 'CANCELLED' && s != 'DRAFT'; } + + bool get isRejected => status.toUpperCase() == 'REJECTED'; + + /// Dedicated reject reason for REJECTED POs (never use header `remarks`). + String? get rejectReasonForDisplay { + if (!isRejected) return null; + final reason = rejectReason?.trim(); + if (reason == null || reason.isEmpty) return null; + return reason; + } } @freezed diff --git a/lib/shared/models/purchase_order_model.freezed.dart b/lib/shared/models/purchase_order_model.freezed.dart index 720d23a..95ca346 100644 --- a/lib/shared/models/purchase_order_model.freezed.dart +++ b/lib/shared/models/purchase_order_model.freezed.dart @@ -69,6 +69,8 @@ mixin _$PurchaseOrderModel { @JsonKey(name: 'terms_and_conditions') String? get termsAndConditions => throw _privateConstructorUsedError; String? get remarks => throw _privateConstructorUsedError; + @JsonKey(name: 'reject_reason', readValue: _readRejectReason) + String? get rejectReason => throw _privateConstructorUsedError; @JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) int? get revisionNo => throw _privateConstructorUsedError; @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) @@ -132,6 +134,8 @@ abstract class $PurchaseOrderModelCopyWith<$Res> { double? totalAmount, @JsonKey(name: 'terms_and_conditions') String? termsAndConditions, String? remarks, + @JsonKey(name: 'reject_reason', readValue: _readRejectReason) + String? rejectReason, @JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) int? revisionNo, @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) @@ -182,6 +186,7 @@ class _$PurchaseOrderModelCopyWithImpl<$Res, $Val extends PurchaseOrderModel> Object? totalAmount = freezed, Object? termsAndConditions = freezed, Object? remarks = freezed, + Object? rejectReason = freezed, Object? revisionNo = freezed, Object? createdAt = freezed, Object? updatedAt = freezed, @@ -289,6 +294,10 @@ class _$PurchaseOrderModelCopyWithImpl<$Res, $Val extends PurchaseOrderModel> ? _value.remarks : remarks // ignore: cast_nullable_to_non_nullable as String?, + rejectReason: freezed == rejectReason + ? _value.rejectReason + : rejectReason // ignore: cast_nullable_to_non_nullable + as String?, revisionNo: freezed == revisionNo ? _value.revisionNo : revisionNo // ignore: cast_nullable_to_non_nullable @@ -358,6 +367,8 @@ abstract class _$$PurchaseOrderModelImplCopyWith<$Res> double? totalAmount, @JsonKey(name: 'terms_and_conditions') String? termsAndConditions, String? remarks, + @JsonKey(name: 'reject_reason', readValue: _readRejectReason) + String? rejectReason, @JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) int? revisionNo, @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) @@ -407,6 +418,7 @@ class __$$PurchaseOrderModelImplCopyWithImpl<$Res> Object? totalAmount = freezed, Object? termsAndConditions = freezed, Object? remarks = freezed, + Object? rejectReason = freezed, Object? revisionNo = freezed, Object? createdAt = freezed, Object? updatedAt = freezed, @@ -514,6 +526,10 @@ class __$$PurchaseOrderModelImplCopyWithImpl<$Res> ? _value.remarks : remarks // ignore: cast_nullable_to_non_nullable as String?, + rejectReason: freezed == rejectReason + ? _value.rejectReason + : rejectReason // ignore: cast_nullable_to_non_nullable + as String?, revisionNo: freezed == revisionNo ? _value.revisionNo : revisionNo // ignore: cast_nullable_to_non_nullable @@ -573,6 +589,8 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel { @JsonKey(name: 'grand_total', readValue: _readGrandTotal) this.totalAmount, @JsonKey(name: 'terms_and_conditions') this.termsAndConditions, this.remarks, + @JsonKey(name: 'reject_reason', readValue: _readRejectReason) + this.rejectReason, @JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) this.revisionNo, @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) @@ -661,6 +679,9 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel { @override final String? remarks; @override + @JsonKey(name: 'reject_reason', readValue: _readRejectReason) + final String? rejectReason; + @override @JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) final int? revisionNo; @override @@ -680,7 +701,7 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel { @override String toString() { - return 'PurchaseOrderModel(id: $id, poNo: $poNo, poDate: $poDate, status: $status, vendorId: $vendorId, vendorName: $vendorName, vendorType: $vendorType, billingId: $billingId, billingName: $billingName, shippingId: $shippingId, shippingName: $shippingName, paymentTermId: $paymentTermId, deliveryTermId: $deliveryTermId, expectedDeliveryDate: $expectedDeliveryDate, discountAmount: $discountAmount, freightCharges: $freightCharges, otherCharges: $otherCharges, taxableAmount: $taxableAmount, taxAmount: $taxAmount, cgst: $cgst, sgst: $sgst, igst: $igst, totalAmount: $totalAmount, termsAndConditions: $termsAndConditions, remarks: $remarks, revisionNo: $revisionNo, createdAt: $createdAt, updatedAt: $updatedAt, items: $items)'; + return 'PurchaseOrderModel(id: $id, poNo: $poNo, poDate: $poDate, status: $status, vendorId: $vendorId, vendorName: $vendorName, vendorType: $vendorType, billingId: $billingId, billingName: $billingName, shippingId: $shippingId, shippingName: $shippingName, paymentTermId: $paymentTermId, deliveryTermId: $deliveryTermId, expectedDeliveryDate: $expectedDeliveryDate, discountAmount: $discountAmount, freightCharges: $freightCharges, otherCharges: $otherCharges, taxableAmount: $taxableAmount, taxAmount: $taxAmount, cgst: $cgst, sgst: $sgst, igst: $igst, totalAmount: $totalAmount, termsAndConditions: $termsAndConditions, remarks: $remarks, rejectReason: $rejectReason, revisionNo: $revisionNo, createdAt: $createdAt, updatedAt: $updatedAt, items: $items)'; } @override @@ -730,6 +751,8 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel { (identical(other.termsAndConditions, termsAndConditions) || other.termsAndConditions == termsAndConditions) && (identical(other.remarks, remarks) || other.remarks == remarks) && + (identical(other.rejectReason, rejectReason) || + other.rejectReason == rejectReason) && (identical(other.revisionNo, revisionNo) || other.revisionNo == revisionNo) && (identical(other.createdAt, createdAt) || @@ -768,6 +791,7 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel { totalAmount, termsAndConditions, remarks, + rejectReason, revisionNo, createdAt, updatedAt, @@ -838,6 +862,8 @@ abstract class _PurchaseOrderModel extends PurchaseOrderModel { final double? totalAmount, @JsonKey(name: 'terms_and_conditions') final String? termsAndConditions, final String? remarks, + @JsonKey(name: 'reject_reason', readValue: _readRejectReason) + final String? rejectReason, @JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) final int? revisionNo, @JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) @@ -925,6 +951,9 @@ abstract class _PurchaseOrderModel extends PurchaseOrderModel { @override String? get remarks; @override + @JsonKey(name: 'reject_reason', readValue: _readRejectReason) + String? get rejectReason; + @override @JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) int? get revisionNo; @override @@ -956,7 +985,11 @@ mixin _$PurchaseOrderItemModel { String get id => throw _privateConstructorUsedError; @JsonKey(name: 'po_id', fromJson: _idFromJson) String? get poId => throw _privateConstructorUsedError; - @JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'item_id', + readValue: _readItemId, + fromJson: _intFromJsonNullable, + ) int? get itemId => throw _privateConstructorUsedError; @JsonKey(name: 'item_code', readValue: _readItemCode) String? get itemCode => throw _privateConstructorUsedError; @@ -1024,7 +1057,12 @@ abstract class $PurchaseOrderItemModelCopyWith<$Res> { $Res call({ @JsonKey(fromJson: _idFromJson) String id, @JsonKey(name: 'po_id', fromJson: _idFromJson) String? poId, - @JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) int? itemId, + @JsonKey( + name: 'item_id', + readValue: _readItemId, + fromJson: _intFromJsonNullable, + ) + int? itemId, @JsonKey(name: 'item_code', readValue: _readItemCode) String? itemCode, @JsonKey(name: 'item_name', readValue: _readItemName) String? itemName, @JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo, @@ -1206,7 +1244,12 @@ abstract class _$$PurchaseOrderItemModelImplCopyWith<$Res> $Res call({ @JsonKey(fromJson: _idFromJson) String id, @JsonKey(name: 'po_id', fromJson: _idFromJson) String? poId, - @JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) int? itemId, + @JsonKey( + name: 'item_id', + readValue: _readItemId, + fromJson: _intFromJsonNullable, + ) + int? itemId, @JsonKey(name: 'item_code', readValue: _readItemCode) String? itemCode, @JsonKey(name: 'item_name', readValue: _readItemName) String? itemName, @JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo, @@ -1378,7 +1421,12 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel { const _$PurchaseOrderItemModelImpl({ @JsonKey(fromJson: _idFromJson) required this.id, @JsonKey(name: 'po_id', fromJson: _idFromJson) this.poId, - @JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) this.itemId, + @JsonKey( + name: 'item_id', + readValue: _readItemId, + fromJson: _intFromJsonNullable, + ) + this.itemId, @JsonKey(name: 'item_code', readValue: _readItemCode) this.itemCode, @JsonKey(name: 'item_name', readValue: _readItemName) this.itemName, @JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) this.lineNo, @@ -1430,7 +1478,11 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel { @JsonKey(name: 'po_id', fromJson: _idFromJson) final String? poId; @override - @JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'item_id', + readValue: _readItemId, + fromJson: _intFromJsonNullable, + ) final int? itemId; @override @JsonKey(name: 'item_code', readValue: _readItemCode) @@ -1587,7 +1639,12 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel { const factory _PurchaseOrderItemModel({ @JsonKey(fromJson: _idFromJson) required final String id, @JsonKey(name: 'po_id', fromJson: _idFromJson) final String? poId, - @JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) final int? itemId, + @JsonKey( + name: 'item_id', + readValue: _readItemId, + fromJson: _intFromJsonNullable, + ) + final int? itemId, @JsonKey(name: 'item_code', readValue: _readItemCode) final String? itemCode, @JsonKey(name: 'item_name', readValue: _readItemName) @@ -1641,7 +1698,11 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel { @JsonKey(name: 'po_id', fromJson: _idFromJson) String? get poId; @override - @JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) + @JsonKey( + name: 'item_id', + readValue: _readItemId, + fromJson: _intFromJsonNullable, + ) int? get itemId; @override @JsonKey(name: 'item_code', readValue: _readItemCode) diff --git a/lib/shared/models/purchase_order_model.g.dart b/lib/shared/models/purchase_order_model.g.dart index 0aa1c97..dee1dfa 100644 --- a/lib/shared/models/purchase_order_model.g.dart +++ b/lib/shared/models/purchase_order_model.g.dart @@ -34,6 +34,7 @@ _$PurchaseOrderModelImpl _$$PurchaseOrderModelImplFromJson( totalAmount: (_readGrandTotal(json, 'grand_total') as num?)?.toDouble(), termsAndConditions: json['terms_and_conditions'] as String?, remarks: json['remarks'] as String?, + rejectReason: _readRejectReason(json, 'reject_reason') as String?, revisionNo: _intFromJsonNullable(json['revision_no']), createdAt: _dateFromJsonNullable(json['created_at']), updatedAt: _dateFromJsonNullable(json['updated_at']), @@ -74,6 +75,7 @@ Map _$$PurchaseOrderModelImplToJson( 'grand_total': instance.totalAmount, 'terms_and_conditions': instance.termsAndConditions, 'remarks': instance.remarks, + 'reject_reason': instance.rejectReason, 'revision_no': instance.revisionNo, 'created_at': instance.createdAt?.toIso8601String(), 'updated_at': instance.updatedAt?.toIso8601String(), diff --git a/lib/shared/widgets/app_date_popup.dart b/lib/shared/widgets/app_date_popup.dart index 0db35e9..fa8dea7 100644 --- a/lib/shared/widgets/app_date_popup.dart +++ b/lib/shared/widgets/app_date_popup.dart @@ -1,5 +1,50 @@ import 'package:flutter/material.dart'; +enum _PickerMode { day, month, year } + +const _monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +const _monthShortNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + +bool _isMonthInRange(int year, int month, DateTime first, DateTime last) { + final start = DateTime(year, month, 1); + final end = DateTime(year, month + 1, 0); + return !end.isBefore(_dateOnly(first)) && !start.isAfter(_dateOnly(last)); +} + +bool _canShiftMonth(DateTime month, int delta, DateTime first, DateTime last) { + final next = DateTime(month.year, month.month + delta); + return _isMonthInRange(next.year, next.month, first, last); +} + /// Compact dialog date picker (avoids the full-page Material picker on web). Future showAppDatePopup({ required BuildContext context, @@ -9,14 +54,20 @@ Future showAppDatePopup({ String helpText = 'Select date', }) { final now = DateTime.now(); + final first = firstDate ?? DateTime(now.year - 30); + final last = lastDate ?? DateTime(now.year + 10); + assert( + !last.isBefore(first), + 'lastDate must be on or after firstDate', + ); return showDialog( context: context, barrierDismissible: true, builder: (context) => _AppDateDialog( helpText: helpText, initialDate: initialDate ?? now, - firstDate: firstDate ?? DateTime(now.year - 30), - lastDate: lastDate ?? DateTime(now.year + 10), + firstDate: first, + lastDate: last, ), ); } @@ -41,6 +92,7 @@ class _AppDateDialog extends StatefulWidget { class _AppDateDialogState extends State<_AppDateDialog> { late DateTime _selected; late DateTime _displayedMonth; + _PickerMode _mode = _PickerMode.day; @override void initState() { @@ -57,23 +109,24 @@ class _AppDateDialogState extends State<_AppDateDialog> { } DateTime _clampDate(DateTime d) { - final first = DateTime( - widget.firstDate.year, - widget.firstDate.month, - widget.firstDate.day, - ); - final last = DateTime( - widget.lastDate.year, - widget.lastDate.month, - widget.lastDate.day, - ); + final first = _dateOnly(widget.firstDate); + final last = _dateOnly(widget.lastDate); if (d.isBefore(first)) return first; if (d.isAfter(last)) return last; return d; } void _shiftMonth(int delta) { + if (!_canShiftMonth( + _displayedMonth, + delta, + widget.firstDate, + widget.lastDate, + )) { + return; + } setState(() { + _mode = _PickerMode.day; _displayedMonth = DateTime( _displayedMonth.year, _displayedMonth.month + delta, @@ -81,9 +134,74 @@ class _AppDateDialogState extends State<_AppDateDialog> { }); } + void _selectYear(int year) { + final month = _displayedMonth.month; + final safeMonth = _isMonthInRange(year, month, widget.firstDate, widget.lastDate) + ? month + : _firstEnabledMonth(year) ?? month; + setState(() { + _displayedMonth = DateTime(year, safeMonth); + _selected = _clampDate( + DateTime(year, safeMonth, _selected.day.clamp(1, _daysInMonth(year, safeMonth))), + ); + _mode = _PickerMode.month; + }); + } + + void _selectMonth(int month) { + if (!_isMonthInRange( + _displayedMonth.year, + month, + widget.firstDate, + widget.lastDate, + )) { + return; + } + setState(() { + _displayedMonth = DateTime(_displayedMonth.year, month); + _selected = _clampDate( + DateTime( + _displayedMonth.year, + month, + _selected.day.clamp(1, _daysInMonth(_displayedMonth.year, month)), + ), + ); + _mode = _PickerMode.day; + }); + } + + int? _firstEnabledMonth(int year) { + for (var m = 1; m <= 12; m++) { + if (_isMonthInRange(year, m, widget.firstDate, widget.lastDate)) { + return m; + } + } + return null; + } + + int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day; + + bool get _canApply { + final d = _dateOnly(_selected); + return !d.isBefore(_dateOnly(widget.firstDate)) && + !d.isAfter(_dateOnly(widget.lastDate)); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final canPrev = _canShiftMonth( + _displayedMonth, + -1, + widget.firstDate, + widget.lastDate, + ); + final canNext = _canShiftMonth( + _displayedMonth, + 1, + widget.firstDate, + widget.lastDate, + ); return Dialog( insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), @@ -114,19 +232,48 @@ class _AppDateDialogState extends State<_AppDateDialog> { ], ), const SizedBox(height: 8), - _MonthHeader( + _MonthYearHeader( month: _displayedMonth, + mode: _mode, + canPrev: canPrev && _mode == _PickerMode.day, + canNext: canNext && _mode == _PickerMode.day, onPrev: () => _shiftMonth(-1), onNext: () => _shiftMonth(1), + onMonthTap: () => setState(() { + _mode = _mode == _PickerMode.month + ? _PickerMode.day + : _PickerMode.month; + }), + onYearTap: () => setState(() { + _mode = _mode == _PickerMode.year + ? _PickerMode.day + : _PickerMode.year; + }), ), const SizedBox(height: 8), - _CalendarGrid( - month: _displayedMonth, - firstDate: widget.firstDate, - lastDate: widget.lastDate, - selected: _selected, - onSelected: (day) => setState(() => _selected = _clampDate(day)), - ), + switch (_mode) { + _PickerMode.year => _YearPickerGrid( + selectedYear: _displayedMonth.year, + firstYear: widget.firstDate.year, + lastYear: widget.lastDate.year, + onSelected: _selectYear, + ), + _PickerMode.month => _MonthPickerGrid( + selectedMonth: _displayedMonth.month, + year: _displayedMonth.year, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + onSelected: _selectMonth, + ), + _PickerMode.day => _CalendarGrid( + month: _displayedMonth, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + selected: _selected, + onSelected: (day) => + setState(() => _selected = _clampDate(day)), + ), + }, const SizedBox(height: 16), Row( children: [ @@ -137,7 +284,9 @@ class _AppDateDialogState extends State<_AppDateDialog> { ), const SizedBox(width: 8), FilledButton( - onPressed: () => Navigator.of(context).pop(_selected), + onPressed: _canApply + ? () => Navigator.of(context).pop(_selected) + : null, child: const Text('Apply'), ), ], @@ -150,54 +299,58 @@ class _AppDateDialogState extends State<_AppDateDialog> { } } -class _MonthHeader extends StatelessWidget { - const _MonthHeader({ +class _MonthYearHeader extends StatelessWidget { + const _MonthYearHeader({ required this.month, + required this.mode, + required this.canPrev, + required this.canNext, required this.onPrev, required this.onNext, + required this.onMonthTap, + required this.onYearTap, }); final DateTime month; + final _PickerMode mode; + final bool canPrev; + final bool canNext; final VoidCallback onPrev; final VoidCallback onNext; + final VoidCallback onMonthTap; + final VoidCallback onYearTap; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - final title = '${months[month.month - 1]} ${month.year}'; - return Row( children: [ IconButton( - onPressed: onPrev, + tooltip: 'Previous month', + onPressed: canPrev ? onPrev : null, icon: const Icon(Icons.chevron_left), visualDensity: VisualDensity.compact, ), Expanded( - child: Text( - title, - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _HeaderChip( + label: _monthNames[month.month - 1], + selected: mode == _PickerMode.month, + onTap: onMonthTap, + ), + const SizedBox(width: 6), + _HeaderChip( + label: '${month.year}', + selected: mode == _PickerMode.year, + onTap: onYearTap, + ), + ], ), ), IconButton( - onPressed: onNext, + tooltip: 'Next month', + onPressed: canNext ? onNext : null, icon: const Icon(Icons.chevron_right), visualDensity: VisualDensity.compact, ), @@ -206,6 +359,190 @@ class _MonthHeader extends StatelessWidget { } } +class _HeaderChip extends StatelessWidget { + const _HeaderChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: selected + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + ), + ), + Icon( + selected ? Icons.arrow_drop_up : Icons.arrow_drop_down, + color: theme.colorScheme.primary, + size: 22, + ), + ], + ), + ), + ), + ); + } +} + +class _MonthPickerGrid extends StatelessWidget { + const _MonthPickerGrid({ + required this.selectedMonth, + required this.year, + required this.firstDate, + required this.lastDate, + required this.onSelected, + }); + + final int selectedMonth; + final int year; + final DateTime firstDate; + final DateTime lastDate; + final ValueChanged onSelected; + + static const double _gridHeight = + _CalendarGrid._weeks * _CalendarGrid._dayExtent + + (_CalendarGrid._weeks - 1) * _CalendarGrid._spacing + + 22; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + height: _gridHeight, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: 12, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 2.2, + ), + itemBuilder: (context, index) { + final month = index + 1; + final enabled = _isMonthInRange(year, month, firstDate, lastDate); + final selected = month == selectedMonth; + return Material( + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest.withValues( + alpha: enabled ? 0.45 : 0.2, + ), + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: enabled ? () => onSelected(month) : null, + child: Center( + child: Text( + _monthShortNames[index], + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: !enabled + ? theme.disabledColor + : selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +class _YearPickerGrid extends StatelessWidget { + const _YearPickerGrid({ + required this.selectedYear, + required this.firstYear, + required this.lastYear, + required this.onSelected, + }); + + final int selectedYear; + final int firstYear; + final int lastYear; + final ValueChanged onSelected; + + static const double _gridHeight = + _CalendarGrid._weeks * _CalendarGrid._dayExtent + + (_CalendarGrid._weeks - 1) * _CalendarGrid._spacing + + 22; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final years = [ + for (var y = firstYear; y <= lastYear; y++) y, + ]; + + return SizedBox( + height: _gridHeight, + child: GridView.builder( + itemCount: years.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.1, + ), + itemBuilder: (context, index) { + final year = years[index]; + final selected = year == selectedYear; + return Material( + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.45, + ), + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => onSelected(year), + child: Center( + child: Text( + '$year', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + ), + ), + ); + }, + ), + ); + } +} + class _CalendarGrid extends StatelessWidget { const _CalendarGrid({ required this.month, @@ -221,6 +558,10 @@ class _CalendarGrid extends StatelessWidget { final DateTime selected; final ValueChanged onSelected; + static const int _weeks = 6; + static const double _dayExtent = 40; + static const double _spacing = 4; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -228,6 +569,9 @@ class _CalendarGrid extends StatelessWidget { final daysInMonth = DateTime(month.year, month.month + 1, 0).day; final leading = firstOfMonth.weekday % 7; const weekdays = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + final gridHeight = _weeks * _dayExtent + (_weeks - 1) * _spacing; + final first = _dateOnly(firstDate); + final last = _dateOnly(lastDate); return Column( children: [ @@ -249,54 +593,54 @@ class _CalendarGrid extends StatelessWidget { .toList(), ), const SizedBox(height: 6), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: leading + daysInMonth, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 7, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - ), - itemBuilder: (context, index) { - if (index < leading) return const SizedBox.shrink(); - final day = index - leading + 1; - final date = DateTime(month.year, month.month, day); - final enabled = !date.isBefore( - DateTime(firstDate.year, firstDate.month, firstDate.day), - ) && - !date.isAfter( - DateTime(lastDate.year, lastDate.month, lastDate.day), - ); - final isSelected = date.year == selected.year && - date.month == selected.month && - date.day == selected.day; + SizedBox( + height: gridHeight, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: 7 * _weeks, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + mainAxisSpacing: _spacing, + crossAxisSpacing: _spacing, + mainAxisExtent: _dayExtent, + ), + itemBuilder: (context, index) { + if (index < leading || index >= leading + daysInMonth) { + return const SizedBox.shrink(); + } + final day = index - leading + 1; + final date = DateTime(month.year, month.month, day); + final enabled = !date.isBefore(first) && !date.isAfter(last); + final isSelected = date.year == selected.year && + date.month == selected.month && + date.day == selected.day; - return Material( - color: isSelected - ? theme.colorScheme.primary - : Colors.transparent, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: enabled ? () => onSelected(date) : null, - child: Center( - child: Text( - '$day', - style: theme.textTheme.bodyMedium?.copyWith( - color: !enabled - ? theme.disabledColor - : isSelected - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurface, - fontWeight: - isSelected ? FontWeight.w700 : FontWeight.w500, + return Material( + color: isSelected + ? theme.colorScheme.primary + : Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: enabled ? () => onSelected(date) : null, + child: Center( + child: Text( + '$day', + style: theme.textTheme.bodyMedium?.copyWith( + color: !enabled + ? theme.disabledColor + : isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + fontWeight: + isSelected ? FontWeight.w700 : FontWeight.w500, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ], ); diff --git a/lib/shared/widgets/app_date_range_popup.dart b/lib/shared/widgets/app_date_range_popup.dart index 69e16eb..77d834f 100644 --- a/lib/shared/widgets/app_date_range_popup.dart +++ b/lib/shared/widgets/app_date_range_popup.dart @@ -2,6 +2,51 @@ import 'package:flutter/material.dart'; import '../../core/utils/formatters.dart'; +enum _PickerMode { day, month, year } + +const _monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +const _monthShortNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + +bool _isMonthInRange(int year, int month, DateTime first, DateTime last) { + final start = DateTime(year, month, 1); + final end = DateTime(year, month + 1, 0); + return !end.isBefore(_dateOnly(first)) && !start.isAfter(_dateOnly(last)); +} + +bool _canShiftMonth(DateTime month, int delta, DateTime first, DateTime last) { + final next = DateTime(month.year, month.month + delta); + return _isMonthInRange(next.year, next.month, first, last); +} + /// Compact dialog date-range picker (avoids the full-page Material picker on web). Future showAppDateRangePopup({ required BuildContext context, @@ -11,14 +56,20 @@ Future showAppDateRangePopup({ String helpText = 'Select date range', }) { final now = DateTime.now(); + final first = firstDate ?? DateTime(now.year - 5); + final last = lastDate ?? DateTime(now.year + 1); + assert( + !last.isBefore(first), + 'lastDate must be on or after firstDate', + ); return showDialog( context: context, barrierDismissible: true, builder: (context) => _AppDateRangeDialog( helpText: helpText, initialDateRange: initialDateRange, - firstDate: firstDate ?? DateTime(now.year - 5), - lastDate: lastDate ?? DateTime(now.year + 1), + firstDate: first, + lastDate: last, ), ); } @@ -44,20 +95,83 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> { DateTime? _start; DateTime? _end; late DateTime _displayedMonth; + _PickerMode _mode = _PickerMode.day; @override void initState() { super.initState(); _start = widget.initialDateRange?.start; _end = widget.initialDateRange?.end; + if (_start != null && _end != null && _end!.isBefore(_start!)) { + final swap = _start; + _start = _end; + _end = swap; + } _displayedMonth = DateTime( (_start ?? DateTime.now()).year, (_start ?? DateTime.now()).month, ); } + void _shiftMonth(int delta) { + if (!_canShiftMonth( + _displayedMonth, + delta, + widget.firstDate, + widget.lastDate, + )) { + return; + } + setState(() { + _mode = _PickerMode.day; + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month + delta, + ); + }); + } + + void _selectYear(int year) { + final month = _displayedMonth.month; + final safeMonth = _isMonthInRange(year, month, widget.firstDate, widget.lastDate) + ? month + : _firstEnabledMonth(year) ?? month; + setState(() { + _displayedMonth = DateTime(year, safeMonth); + _mode = _PickerMode.month; + }); + } + + void _selectMonth(int month) { + if (!_isMonthInRange( + _displayedMonth.year, + month, + widget.firstDate, + widget.lastDate, + )) { + return; + } + setState(() { + _displayedMonth = DateTime(_displayedMonth.year, month); + _mode = _PickerMode.day; + }); + } + + int? _firstEnabledMonth(int year) { + for (var m = 1; m <= 12; m++) { + if (_isMonthInRange(year, m, widget.firstDate, widget.lastDate)) { + return m; + } + } + return null; + } + void _onDaySelected(DateTime day) { - final selected = DateTime(day.year, day.month, day.day); + final selected = _dateOnly(day); + final first = _dateOnly(widget.firstDate); + final last = _dateOnly(widget.lastDate); + if (selected.isBefore(first) || selected.isAfter(last)) return; + setState(() { if (_start == null || (_start != null && _end != null)) { _start = selected; @@ -73,28 +187,37 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> { bool _isInRange(DateTime day) { if (_start == null || _end == null) return false; - final d = DateTime(day.year, day.month, day.day); + final d = _dateOnly(day); return !d.isBefore(_start!) && !d.isAfter(_end!); } bool _isEndpoint(DateTime day) { - final d = DateTime(day.year, day.month, day.day); + final d = _dateOnly(day); return (_start != null && d == _start) || (_end != null && d == _end); } - void _shiftMonth(int delta) { - setState(() { - _displayedMonth = DateTime( - _displayedMonth.year, - _displayedMonth.month + delta, - ); - }); - } + bool get _canApply => + _start != null && + _end != null && + !_end!.isBefore(_start!) && + !_start!.isBefore(_dateOnly(widget.firstDate)) && + !_end!.isAfter(_dateOnly(widget.lastDate)); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final canApply = _start != null && _end != null; + final canPrev = _canShiftMonth( + _displayedMonth, + -1, + widget.firstDate, + widget.lastDate, + ); + final canNext = _canShiftMonth( + _displayedMonth, + 1, + widget.firstDate, + widget.lastDate, + ); final rangeLabel = _start == null ? 'Select start date' : _end == null @@ -137,20 +260,48 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> { ), ), const SizedBox(height: 12), - _MonthHeader( + _MonthYearHeader( month: _displayedMonth, + mode: _mode, + canPrev: canPrev && _mode == _PickerMode.day, + canNext: canNext && _mode == _PickerMode.day, onPrev: () => _shiftMonth(-1), onNext: () => _shiftMonth(1), + onMonthTap: () => setState(() { + _mode = _mode == _PickerMode.month + ? _PickerMode.day + : _PickerMode.month; + }), + onYearTap: () => setState(() { + _mode = _mode == _PickerMode.year + ? _PickerMode.day + : _PickerMode.year; + }), ), const SizedBox(height: 8), - _CalendarGrid( - month: _displayedMonth, - firstDate: widget.firstDate, - lastDate: widget.lastDate, - isInRange: _isInRange, - isEndpoint: _isEndpoint, - onSelected: _onDaySelected, - ), + switch (_mode) { + _PickerMode.year => _YearPickerGrid( + selectedYear: _displayedMonth.year, + firstYear: widget.firstDate.year, + lastYear: widget.lastDate.year, + onSelected: _selectYear, + ), + _PickerMode.month => _MonthPickerGrid( + selectedMonth: _displayedMonth.month, + year: _displayedMonth.year, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + onSelected: _selectMonth, + ), + _PickerMode.day => _CalendarGrid( + month: _displayedMonth, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + isInRange: _isInRange, + isEndpoint: _isEndpoint, + onSelected: _onDaySelected, + ), + }, const SizedBox(height: 16), Row( children: [ @@ -168,7 +319,7 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> { ), const SizedBox(width: 8), FilledButton( - onPressed: canApply + onPressed: _canApply ? () => Navigator.of(context).pop( DateTimeRange(start: _start!, end: _end!), ) @@ -185,54 +336,58 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> { } } -class _MonthHeader extends StatelessWidget { - const _MonthHeader({ +class _MonthYearHeader extends StatelessWidget { + const _MonthYearHeader({ required this.month, + required this.mode, + required this.canPrev, + required this.canNext, required this.onPrev, required this.onNext, + required this.onMonthTap, + required this.onYearTap, }); final DateTime month; + final _PickerMode mode; + final bool canPrev; + final bool canNext; final VoidCallback onPrev; final VoidCallback onNext; + final VoidCallback onMonthTap; + final VoidCallback onYearTap; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - final title = '${months[month.month - 1]} ${month.year}'; - return Row( children: [ IconButton( - onPressed: onPrev, + tooltip: 'Previous month', + onPressed: canPrev ? onPrev : null, icon: const Icon(Icons.chevron_left), visualDensity: VisualDensity.compact, ), Expanded( - child: Text( - title, - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _HeaderChip( + label: _monthNames[month.month - 1], + selected: mode == _PickerMode.month, + onTap: onMonthTap, + ), + const SizedBox(width: 6), + _HeaderChip( + label: '${month.year}', + selected: mode == _PickerMode.year, + onTap: onYearTap, + ), + ], ), ), IconButton( - onPressed: onNext, + tooltip: 'Next month', + onPressed: canNext ? onNext : null, icon: const Icon(Icons.chevron_right), visualDensity: VisualDensity.compact, ), @@ -241,6 +396,190 @@ class _MonthHeader extends StatelessWidget { } } +class _HeaderChip extends StatelessWidget { + const _HeaderChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: selected + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + ), + ), + Icon( + selected ? Icons.arrow_drop_up : Icons.arrow_drop_down, + color: theme.colorScheme.primary, + size: 22, + ), + ], + ), + ), + ), + ); + } +} + +class _MonthPickerGrid extends StatelessWidget { + const _MonthPickerGrid({ + required this.selectedMonth, + required this.year, + required this.firstDate, + required this.lastDate, + required this.onSelected, + }); + + final int selectedMonth; + final int year; + final DateTime firstDate; + final DateTime lastDate; + final ValueChanged onSelected; + + static const double _gridHeight = + _CalendarGrid._weeks * _CalendarGrid._dayExtent + + (_CalendarGrid._weeks - 1) * _CalendarGrid._spacing + + 22; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + height: _gridHeight, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: 12, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 2.2, + ), + itemBuilder: (context, index) { + final month = index + 1; + final enabled = _isMonthInRange(year, month, firstDate, lastDate); + final selected = month == selectedMonth; + return Material( + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest.withValues( + alpha: enabled ? 0.45 : 0.2, + ), + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: enabled ? () => onSelected(month) : null, + child: Center( + child: Text( + _monthShortNames[index], + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: !enabled + ? theme.disabledColor + : selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +class _YearPickerGrid extends StatelessWidget { + const _YearPickerGrid({ + required this.selectedYear, + required this.firstYear, + required this.lastYear, + required this.onSelected, + }); + + final int selectedYear; + final int firstYear; + final int lastYear; + final ValueChanged onSelected; + + static const double _gridHeight = + _CalendarGrid._weeks * _CalendarGrid._dayExtent + + (_CalendarGrid._weeks - 1) * _CalendarGrid._spacing + + 22; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final years = [ + for (var y = firstYear; y <= lastYear; y++) y, + ]; + + return SizedBox( + height: _gridHeight, + child: GridView.builder( + itemCount: years.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 2.1, + ), + itemBuilder: (context, index) { + final year = years[index]; + final selected = year == selectedYear; + return Material( + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.45, + ), + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => onSelected(year), + child: Center( + child: Text( + '$year', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + ), + ), + ); + }, + ), + ); + } +} + class _CalendarGrid extends StatelessWidget { const _CalendarGrid({ required this.month, @@ -258,15 +597,20 @@ class _CalendarGrid extends StatelessWidget { final bool Function(DateTime day) isEndpoint; final ValueChanged onSelected; + static const int _weeks = 6; + static const double _dayExtent = 40; + static const double _spacing = 4; + @override Widget build(BuildContext context) { final theme = Theme.of(context); final firstOfMonth = DateTime(month.year, month.month); final daysInMonth = DateTime(month.year, month.month + 1, 0).day; - // DateTime.weekday: Mon=1..Sun=7 → make Sunday-first grid final leading = firstOfMonth.weekday % 7; - - final weekdays = const ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + const weekdays = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + final gridHeight = _weeks * _dayExtent + (_weeks - 1) * _spacing; + final first = _dateOnly(firstDate); + final last = _dateOnly(lastDate); return Column( children: [ @@ -288,54 +632,55 @@ class _CalendarGrid extends StatelessWidget { .toList(), ), const SizedBox(height: 6), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: leading + daysInMonth, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 7, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - ), - itemBuilder: (context, index) { - if (index < leading) return const SizedBox.shrink(); - final day = index - leading + 1; - final date = DateTime(month.year, month.month, day); - final enabled = !date.isBefore( - DateTime(firstDate.year, firstDate.month, firstDate.day), - ) && - !date.isAfter( - DateTime(lastDate.year, lastDate.month, lastDate.day), - ); - final endpoint = isEndpoint(date); - final inRange = isInRange(date); + SizedBox( + height: gridHeight, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: 7 * _weeks, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + mainAxisSpacing: _spacing, + crossAxisSpacing: _spacing, + mainAxisExtent: _dayExtent, + ), + itemBuilder: (context, index) { + if (index < leading || index >= leading + daysInMonth) { + return const SizedBox.shrink(); + } + final day = index - leading + 1; + final date = DateTime(month.year, month.month, day); + final enabled = !date.isBefore(first) && !date.isAfter(last); + final endpoint = isEndpoint(date); + final inRange = isInRange(date); - return Material( - color: endpoint - ? theme.colorScheme.primary - : inRange - ? theme.colorScheme.primary.withValues(alpha: 0.12) - : Colors.transparent, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: enabled ? () => onSelected(date) : null, - child: Center( - child: Text( - '$day', - style: theme.textTheme.bodyMedium?.copyWith( - color: !enabled - ? theme.disabledColor - : endpoint - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurface, - fontWeight: endpoint ? FontWeight.w700 : FontWeight.w500, + return Material( + color: endpoint + ? theme.colorScheme.primary + : inRange + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: enabled ? () => onSelected(date) : null, + child: Center( + child: Text( + '$day', + style: theme.textTheme.bodyMedium?.copyWith( + color: !enabled + ? theme.disabledColor + : endpoint + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + fontWeight: + endpoint ? FontWeight.w700 : FontWeight.w500, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ], ); diff --git a/lib/shared/widgets/app_search_field.dart b/lib/shared/widgets/app_search_field.dart index b058cdb..537a129 100644 --- a/lib/shared/widgets/app_search_field.dart +++ b/lib/shared/widgets/app_search_field.dart @@ -37,6 +37,7 @@ class AppSearchField extends StatelessWidget { isDense: true, suffixIcon: controller.text.isNotEmpty ? IconButton( + tooltip: 'Clear', icon: const Icon(Icons.clear, size: 18), onPressed: () { controller.clear(); diff --git a/lib/shared/widgets/app_segmented_tab_bar.dart b/lib/shared/widgets/app_segmented_tab_bar.dart new file mode 100644 index 0000000..62f5d22 --- /dev/null +++ b/lib/shared/widgets/app_segmented_tab_bar.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +class AppSegmentedTab { + const AppSegmentedTab({ + required this.label, + this.icon, + }); + + final String label; + final IconData? icon; +} + +/// Compact pill / segmented tab strip used across detail and hub screens. +/// +/// Provide either [controller] or both [selectedIndex] and [onChanged]. +class AppSegmentedTabBar extends StatelessWidget { + const AppSegmentedTabBar({ + super.key, + required this.tabs, + this.controller, + this.selectedIndex, + this.onChanged, + }) : assert( + controller != null || (selectedIndex != null && onChanged != null), + 'Provide a TabController, or selectedIndex + onChanged', + ); + + final TabController? controller; + final int? selectedIndex; + final ValueChanged? onChanged; + final List tabs; + + int get _index => controller?.index ?? selectedIndex ?? 0; + + void _select(int index) { + if (controller != null) { + controller!.animateTo(index); + } else { + onChanged?.call(index); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + Widget buildBar(int index) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: scheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final wrap = constraints.maxWidth < 560; + final children = [ + for (var i = 0; i < tabs.length; i++) + _Segment( + tab: tabs[i], + selected: index == i, + onTap: () => _select(i), + expanded: !wrap, + ), + ]; + + if (wrap) { + return Wrap( + spacing: 4, + runSpacing: 4, + children: children, + ); + } + + return Row( + children: [ + for (var i = 0; i < children.length; i++) + Expanded(child: children[i]), + ], + ); + }, + ), + ); + } + + if (controller != null) { + return AnimatedBuilder( + animation: controller!, + builder: (context, _) => buildBar(controller!.index), + ); + } + + return buildBar(_index); + } +} + +class _Segment extends StatelessWidget { + const _Segment({ + required this.tab, + required this.selected, + required this.onTap, + required this.expanded, + }); + + final AppSegmentedTab tab; + final bool selected; + final VoidCallback onTap; + final bool expanded; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final child = AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: selected ? scheme.primary : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: selected + ? [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.28), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min, + children: [ + if (tab.icon != null) ...[ + Icon( + tab.icon, + size: 16, + color: selected ? scheme.onPrimary : scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + ], + Flexible( + child: Text( + tab.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge?.copyWith( + color: selected ? scheme.onPrimary : scheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ], + ), + ); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: child, + ), + ); + } +} diff --git a/lib/shared/widgets/app_side_panel.dart b/lib/shared/widgets/app_side_panel.dart index f4fc7ac..79765a0 100644 --- a/lib/shared/widgets/app_side_panel.dart +++ b/lib/shared/widgets/app_side_panel.dart @@ -392,6 +392,7 @@ class _SidePanelScaffoldState extends State { duration: const Duration(milliseconds: 180), opacity: blocked ? 0.45 : 1, child: IconButton( + tooltip: 'Close', icon: const Icon(Icons.close), onPressed: () => _close(context), ), diff --git a/lib/shared/widgets/app_sticky_form_layout.dart b/lib/shared/widgets/app_sticky_form_layout.dart new file mode 100644 index 0000000..71b2715 --- /dev/null +++ b/lib/shared/widgets/app_sticky_form_layout.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +/// Full-page form layout with a fixed header (title + actions) and a +/// scrollable body. Use for Add/Edit screens like Asset, PO, and GRN. +class AppStickyFormLayout extends StatelessWidget { + const AppStickyFormLayout({ + super.key, + required this.header, + required this.body, + this.scrollController, + this.headerPadding = const EdgeInsets.fromLTRB(16, 12, 16, 12), + this.bodyPadding = const EdgeInsets.fromLTRB(16, 16, 16, 32), + }); + + final Widget header; + final Widget body; + final ScrollController? scrollController; + final EdgeInsetsGeometry headerPadding; + final EdgeInsetsGeometry bodyPadding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Material( + color: scheme.surface, + elevation: 0, + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.surface, + border: Border( + bottom: BorderSide( + color: scheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: headerPadding, + child: header, + ), + ), + ), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: bodyPadding, + child: body, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/master_inline_quick_add_form.dart b/lib/shared/widgets/master_inline_quick_add_form.dart index 97b4730..8474ad0 100644 --- a/lib/shared/widgets/master_inline_quick_add_form.dart +++ b/lib/shared/widgets/master_inline_quick_add_form.dart @@ -401,7 +401,7 @@ class _MasterInlineQuickAddFormState color: Colors.white, ), ) - : Text('Add ${_definition.title}'), + : Text('Save ${_definition.title}'), ), ], ), diff --git a/test/core/validators_test.dart b/test/core/validators_test.dart index 476030a..4e01c09 100644 --- a/test/core/validators_test.dart +++ b/test/core/validators_test.dart @@ -207,4 +207,58 @@ void main() { ); }); }); + + group('password', () { + test('accepts strong passwords', () { + expect(Validators.password('Abcdef1!'), isNull); + expect(Validators.password('Secure@Pass9'), isNull); + }); + + test('requires non-empty value', () { + expect(Validators.password(null), 'Password is required'); + expect(Validators.password(''), 'Password is required'); + }); + + test('enforces minimum length', () { + expect( + Validators.password('Ab1!xyz'), + 'Password must be at least 8 characters', + ); + }); + + test('requires uppercase letter', () { + expect( + Validators.password('abcdef1!'), + contains('uppercase'), + ); + }); + + test('requires lowercase letter', () { + expect( + Validators.password('ABCDEF1!'), + contains('lowercase'), + ); + }); + + test('requires numeric digit', () { + expect( + Validators.password('Abcdefg!'), + contains('numeric digit'), + ); + }); + + test('requires special character', () { + expect( + Validators.password('Abcdefg1'), + contains('special character'), + ); + }); + + test('optionalPassword allows empty', () { + expect(Validators.optionalPassword(null), isNull); + expect(Validators.optionalPassword(''), isNull); + expect(Validators.optionalPassword('Abcdefg1'), isNotNull); + expect(Validators.optionalPassword('Abcdef1!'), isNull); + }); + }); }