review changes fix
This commit is contained in:
parent
0978f9bfac
commit
b3a0366848
@ -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.
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'formatters.dart';
|
||||
|
||||
class Validators {
|
||||
Validators._();
|
||||
|
||||
@ -137,15 +139,35 @@ class Validators {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Strong password policy used for create / change / reset flows.
|
||||
///
|
||||
/// Requires: 8+ chars, uppercase, lowercase, digit, and a special character.
|
||||
static String? password(String? value) {
|
||||
if (value == null || value.isEmpty) return 'Password is required';
|
||||
if (value.length < 8) return 'Password must be at least 8 characters';
|
||||
if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Must contain an uppercase letter';
|
||||
if (!RegExp(r'[a-z]').hasMatch(value)) return 'Must contain a lowercase letter';
|
||||
if (!RegExp(r'[0-9]').hasMatch(value)) return 'Must contain a number';
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
if (!RegExp(r'[A-Z]').hasMatch(value)) {
|
||||
return 'Password must contain at least one uppercase letter (A–Z)';
|
||||
}
|
||||
if (!RegExp(r'[a-z]').hasMatch(value)) {
|
||||
return 'Password must contain at least one lowercase letter (a–z)';
|
||||
}
|
||||
if (!RegExp(r'[0-9]').hasMatch(value)) {
|
||||
return 'Password must contain at least one numeric digit (0–9)';
|
||||
}
|
||||
if (!RegExp(r'''[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\;/`'~]''').hasMatch(value)) {
|
||||
return 'Password must contain at least one special character (e.g. @, #, \$, %, &, !)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Same as [password], but allows empty (e.g. edit user keep current password).
|
||||
static String? optionalPassword(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return password(value);
|
||||
}
|
||||
|
||||
/// Required 15-character GSTIN pattern.
|
||||
static String? gstin(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
@ -212,7 +234,7 @@ class Validators {
|
||||
String fieldName = 'Value',
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final parsed = double.tryParse(value.trim());
|
||||
final parsed = CurrencyFormatter.tryParse(value);
|
||||
if (parsed == null) return 'Enter a valid number';
|
||||
if (parsed <= 0) return '$fieldName must be greater than 0';
|
||||
return null;
|
||||
@ -224,7 +246,7 @@ class Validators {
|
||||
String fieldName = 'Value',
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final parsed = double.tryParse(value.trim());
|
||||
final parsed = CurrencyFormatter.tryParse(value);
|
||||
if (parsed == null) return 'Enter a valid number';
|
||||
if (parsed < 0) return '$fieldName cannot be negative';
|
||||
return null;
|
||||
@ -248,7 +270,7 @@ class Validators {
|
||||
String fieldName = 'Percentage',
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final parsed = double.tryParse(value.trim());
|
||||
final parsed = CurrencyFormatter.tryParse(value);
|
||||
if (parsed == null) return 'Enter a valid percentage';
|
||||
if (parsed < 0 || parsed > 100) {
|
||||
return '$fieldName must be between 0 and 100';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) ...[
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
26
lib/modules/vendors/presentation/providers/vendor_lookups_provider.dart
vendored
Normal file
26
lib/modules/vendors/presentation/providers/vendor_lookups_provider.dart
vendored
Normal 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();
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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();
|
||||
|
||||
173
lib/shared/widgets/app_segmented_tab_bar.dart
Normal file
173
lib/shared/widgets/app_segmented_tab_bar.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
|
||||
64
lib/shared/widgets/app_sticky_form_layout.dart
Normal file
64
lib/shared/widgets/app_sticky_form_layout.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -401,7 +401,7 @@ class _MasterInlineQuickAddFormState
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text('Add ${_definition.title}'),
|
||||
: Text('Save ${_definition.title}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user