review changes fix

This commit is contained in:
Surendiran 2026-07-23 17:59:45 +05:30
parent 0978f9bfac
commit b3a0366848
50 changed files with 2583 additions and 1210 deletions

View File

@ -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<TextInputFormatter> 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.

View File

@ -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 (AZ)';
}
if (!RegExp(r'[a-z]').hasMatch(value)) {
return 'Password must contain at least one lowercase letter (az)';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Password must contain at least one numeric digit (09)';
}
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';

View File

@ -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<List<AssetCategoryModel>>((ref) async {
final itemCategoriesProvider =
FutureProvider.autoDispose<List<AssetCategoryModel>>((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<List<AssetCategoryModel>>((ref) as
/// Categories for Asset form dropdowns (`dropdown_call=true`).
final itemCategoriesFormProvider =
FutureProvider<List<AssetCategoryModel>>((ref) async {
FutureProvider.autoDispose<List<AssetCategoryModel>>((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<List<FilterOptionModel>, int?>((ref, categoryId) async {
if (categoryId == null) return [];
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listItemSubcategories(itemCategoryId: categoryId);
});
@Deprecated('Use itemCategoriesProvider')
final assetCategoriesProvider = itemCategoriesProvider;

View File

@ -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<FilterOptionModel> locations,
List<AssetDropdownOption> statuses,

View File

@ -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<AssetAlertsScreen>
),
],
),
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),

View File

@ -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<AssetDetailScreen>
],
),
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) ...[

View File

@ -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<AssetFormScreen> {
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<AssetFormScreen> {
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<AssetFormScreen> {
_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<AssetFormScreen> {
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<AssetFormScreen> {
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<AssetFormScreen> {
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<AssetFormScreen> {
}
}
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<AssetFormScreen> {
);
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<AssetFormScreen> {
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<AssetFormScreen> {
),
],
),
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<AssetFormScreen> {
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<AssetFormScreen> {
),
],
),
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<String>(
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<String>(
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<AssetFormScreen> {
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<AssetFormScreen> {
),
],
),
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<List<FilterOptionModel>, int?>((ref, categoryId) async {
if (categoryId == null) return [];
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listItemSubcategories(itemCategoryId: categoryId);
});

View File

@ -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,
);
}
}

View File

@ -65,10 +65,6 @@ class _SubmitMaintenanceLogPanelState
DateTime _performedDate = DateTime.now();
late final List<_ChecklistRowState> _rows;
bool _isSubmitting = false;
bool _showLogs = false;
List<AssetMaintenanceLogModel>? _logs;
bool _logsLoading = false;
String? _logsError;
@override
void initState() {
@ -107,27 +103,6 @@ class _SubmitMaintenanceLogPanelState
}
}
Future<void> _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<void> _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<Widget>? leadingActions;
@override
ConsumerState<AssetRecentMaintenanceLogsSection> createState() =>
_AssetRecentMaintenanceLogsSectionState();
}
class _AssetRecentMaintenanceLogsSectionState
extends ConsumerState<AssetRecentMaintenanceLogsSection> {
bool _showLogs = false;
List<AssetMaintenanceLogModel>? _logs;
bool _logsLoading = false;
String? _logsError;
Future<void> _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,

View File

@ -82,7 +82,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
_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<AddAmcPanel> {
}
Map<String, dynamic> _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<AddAmcPanel> {
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<AddAmcPanel> {
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<AddAmcPanel> {
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<AddAmcPanel> {
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<LogServiceVisitPanel> {
_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<LogServiceVisitPanel> {
}
Map<String, dynamic> _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<LogServiceVisitPanel> {
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<LogServiceVisitPanel> {
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<LogServiceVisitPanel> {
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<AddInsurancePanel> {
_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<AddInsurancePanel> {
}
Map<String, dynamic> _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<AddInsurancePanel> {
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<AddInsurancePanel> {
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<AddInsurancePanel> {
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<AddInsurancePanel> {
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<AddInsurancePanel> {
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Add Insurance Policy',
saveLabel: 'Save Insurance Policy',
onSave: _save,
),
child: _buildForm(),

View File

@ -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<LoginScreen>
);
}
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<LoginScreen>
);
}
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<LoginScreen>
}) {
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<LoginScreen>
),
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<LoginScreen>
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<LoginScreen>
_backToSignInButton(colors),
const SizedBox(height: 18),
_secureAccessFooter(colors),
const SizedBox(height: 22),
_versionLabel(colors),
],
),
);
@ -742,7 +737,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
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<LoginScreen>
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<LoginScreen>
_backToSignInButton(colors),
const SizedBox(height: 18),
_secureAccessFooter(colors),
const SizedBox(height: 22),
_versionLabel(colors),
],
),
);
@ -944,6 +937,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
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,

View File

@ -52,7 +52,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
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),

View File

@ -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,
),
),
],
);
}

View File

@ -61,7 +61,7 @@ class _BranchFormScreenState extends State<BranchFormScreen> {
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();
},

View File

@ -89,7 +89,7 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
),
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();

View File

@ -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,
),
),
),
),
),

View File

@ -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<GrnFormScreen> {
.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<GrnFormScreen> {
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
padding: EdgeInsets.zero,
child: LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 720;

View File

@ -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);
}
}

View File

@ -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<MasterListState, String> {
}
await refresh();
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
return true;
}
@ -644,6 +646,9 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
}
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
// Keep Asset/PO/GRN/User/Vendor dropdown caches in sync with Master Data.
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
ref.invalidate(masterListProvider(_definition.id));
final data = result.data;
final createdId = _readCreatedId(data);
if (createdId != null && createdId.isNotEmpty) return createdId;

View File

@ -21,6 +21,7 @@ import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../domain/entities/master_definition.dart';
import '../providers/master_provider.dart';
import '../providers/master_consumer_invalidation.dart';
import '../widgets/master_form_panel.dart';
import '../../../../shared/widgets/app_toast.dart';
@ -125,7 +126,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
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<MasterListScreen> {
await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id);
if (!mounted) return;
if (success) {
invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
}
showAppToastFromSnackBar(context,
SnackBar(
content: Text(

View File

@ -489,7 +489,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
),
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,

View File

@ -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,

View File

@ -134,11 +134,11 @@ class PurchaseOrderRemoteDataSource {
Future<PurchaseOrderModel> 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<String, dynamic>,

View File

@ -98,10 +98,10 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
@override
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
String id, {
required String remarks,
required String rejectReason,
}) {
return safeApiCall(
() => dataSource.rejectPurchaseOrder(id, remarks: remarks),
() => dataSource.rejectPurchaseOrder(id, rejectReason: rejectReason),
);
}

View File

@ -27,7 +27,7 @@ abstract class PurchaseOrderRepository {
Future<Result<PurchaseOrderModel>> approvePurchaseOrder(String id, {String? remarks});
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
String id, {
required String remarks,
required String rejectReason,
});
Future<Result<PurchaseOrderModel>> amendPurchaseOrder(
String id, {

View File

@ -395,9 +395,12 @@ class PurchaseOrderDetailNotifier
return result.data!;
}
Future<PurchaseOrderModel> reject({required String remarks}) async {
Future<PurchaseOrderModel> 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);

View File

@ -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<void> _reject(PurchaseOrderModel order) async {
final remarksController = TextEditingController();
final reasonController = TextEditingController();
final confirmed = await showDialog<bool>(
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),

View File

@ -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<PurchaseOrderFormScree
final totals = _computeTotals(lookups.gstRatePctById);
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: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(existing),
if (_showReapprovalWarning(existing)) ...[
const SizedBox(height: 8),
_ReapprovalBanner(),
],
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(existing),
if (_showReapprovalWarning(existing)) ...[
const SizedBox(height: 8),
_ReapprovalBanner(),
],
const SizedBox(height: 16),
_SectionCard(
title: 'ORDER DETAILS',
child: QuickAddInlineHost(
@ -683,7 +689,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
const Spacer(),
Text(
widget.isEditing
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft'
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing ${existing?.isRejected == true ? 'rejected' : 'existing draft'} order'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
@ -758,7 +764,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
padding: EdgeInsets.zero,
child: LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 720;

View File

@ -548,11 +548,19 @@ class _PoDataTable extends StatelessWidget {
label: 'Status',
flex: 1,
searchText: (order) => 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',

View File

@ -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<void> _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<void> _resetPassword(ManagedUserModel user) async {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
final password = await showDialog<String>(
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'),
),

View File

@ -41,6 +41,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
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<AddUserPanel> {
),
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<AddUserPanel> {
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(

View File

@ -113,7 +113,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
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,

View File

@ -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,

View File

@ -272,7 +272,7 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
children: [
Expanded(
child: AppButton(
label: isEditing ? 'Update User' : 'Create User',
label: isEditing ? 'Update User' : 'Save User',
isLoading: _isSubmitting,
onPressed: _submit,
),

View File

@ -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<UsersRolesHubScreen>
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,
),
],
),
),

View File

@ -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<FilterOptionModel> options,
Map<int, int> creditDaysById,
})>((ref) async {
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listPaymentTermsWithCreditDays();
});
final vendorGstTreatmentsProvider =
FutureProvider.autoDispose<List<FilterOptionModel>>((ref) async {
final dataSource = ref.watch(vendorRemoteDataSourceProvider);
return dataSource.getGstTreatments();
});
final vendorSourceOfSupplyProvider =
FutureProvider.autoDispose<List<FilterOptionModel>>((ref) async {
final dataSource = ref.watch(vendorRemoteDataSourceProvider);
return dataSource.getSourceOfSupply();
});

View File

@ -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<VendorDetailScreen>
],
),
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,

View File

@ -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<VendorFormPanel> {
);
}
}
final vendorPaymentTermsProvider = FutureProvider<
({
List<FilterOptionModel> options,
Map<int, int> creditDaysById,
})>((ref) async {
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listPaymentTermsWithCreditDays();
});
final vendorGstTreatmentsProvider =
FutureProvider<List<FilterOptionModel>>((ref) async {
final dataSource = ref.watch(vendorRemoteDataSourceProvider);
return dataSource.getGstTreatments();
});
final vendorSourceOfSupplyProvider =
FutureProvider<List<FilterOptionModel>>((ref) async {
final dataSource = ref.watch(vendorRemoteDataSourceProvider);
return dataSource.getSourceOfSupply();
});

View File

@ -158,7 +158,7 @@ class _VendorAddressPanelState extends ConsumerState<VendorAddressPanel> {
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<VendorContactPanel> {
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<VendorBankDetailPanel> {
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(

View File

@ -171,6 +171,20 @@ Object? _readTaxTotal(Map<dynamic, dynamic> json, String key) =>
Object? _readSubTotal(Map<dynamic, dynamic> json, String key) =>
_doubleFromJsonNullable(json['sub_total'] ?? json['taxable_amount']);
Object? _readRejectReason(Map<dynamic, dynamic> 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<String, dynamic> 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

View File

@ -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)

View File

@ -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<String, dynamic> _$$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(),

View File

@ -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<DateTime?> showAppDatePopup({
required BuildContext context,
@ -9,14 +54,20 @@ Future<DateTime?> 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<DateTime>(
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<int> 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<int> 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<DateTime> 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,
),
),
),
),
),
);
},
);
},
),
),
],
);

View File

@ -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<DateTimeRange?> showAppDateRangePopup({
required BuildContext context,
@ -11,14 +56,20 @@ Future<DateTimeRange?> 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<DateTimeRange>(
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<int> 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<int> 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<DateTime> 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,
),
),
),
),
),
);
},
);
},
),
),
],
);

View File

@ -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();

View File

@ -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<int>? onChanged;
final List<AppSegmentedTab> 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,
),
);
}
}

View File

@ -392,6 +392,7 @@ class _SidePanelScaffoldState extends State<SidePanelScaffold> {
duration: const Duration(milliseconds: 180),
opacity: blocked ? 0.45 : 1,
child: IconButton(
tooltip: 'Close',
icon: const Icon(Icons.close),
onPressed: () => _close(context),
),

View File

@ -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,
),
),
],
);
}
}

View File

@ -401,7 +401,7 @@ class _MasterInlineQuickAddFormState
color: Colors.white,
),
)
: Text('Add ${_definition.title}'),
: Text('Save ${_definition.title}'),
),
],
),

View File

@ -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);
});
});
}