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';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class DateFormatter {
|
class DateFormatter {
|
||||||
@ -63,8 +64,10 @@ class DateFormatter {
|
|||||||
class CurrencyFormatter {
|
class CurrencyFormatter {
|
||||||
CurrencyFormatter._();
|
CurrencyFormatter._();
|
||||||
|
|
||||||
|
static const locale = 'en_IN';
|
||||||
|
|
||||||
static final _formatter = NumberFormat.currency(
|
static final _formatter = NumberFormat.currency(
|
||||||
locale: 'en_IN',
|
locale: locale,
|
||||||
symbol: '₹',
|
symbol: '₹',
|
||||||
decimalDigits: 2,
|
decimalDigits: 2,
|
||||||
);
|
);
|
||||||
@ -74,11 +77,107 @@ class CurrencyFormatter {
|
|||||||
return _formatter.format(amount);
|
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.
|
/// Formatted + raw numeric text for client-side column search.
|
||||||
static String searchable(double? amount) {
|
static String searchable(double? amount) {
|
||||||
if (amount == null) return '';
|
if (amount == null) return '';
|
||||||
return '${format(amount)} $amount';
|
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.
|
/// Converts `snake_case` / `kebab-case` keys into readable Title Case labels.
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'formatters.dart';
|
||||||
|
|
||||||
class Validators {
|
class Validators {
|
||||||
Validators._();
|
Validators._();
|
||||||
|
|
||||||
@ -137,15 +139,35 @@ class Validators {
|
|||||||
return null;
|
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) {
|
static String? password(String? value) {
|
||||||
if (value == null || value.isEmpty) return 'Password is required';
|
if (value == null || value.isEmpty) return 'Password is required';
|
||||||
if (value.length < 8) return 'Password must be at least 8 characters';
|
if (value.length < 8) {
|
||||||
if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Must contain an uppercase letter';
|
return 'Password must be at least 8 characters';
|
||||||
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 (!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;
|
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.
|
/// Required 15-character GSTIN pattern.
|
||||||
static String? gstin(String? value) {
|
static String? gstin(String? value) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
@ -212,7 +234,7 @@ class Validators {
|
|||||||
String fieldName = 'Value',
|
String fieldName = 'Value',
|
||||||
}) {
|
}) {
|
||||||
if (value == null || value.trim().isEmpty) return null;
|
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 == null) return 'Enter a valid number';
|
||||||
if (parsed <= 0) return '$fieldName must be greater than 0';
|
if (parsed <= 0) return '$fieldName must be greater than 0';
|
||||||
return null;
|
return null;
|
||||||
@ -224,7 +246,7 @@ class Validators {
|
|||||||
String fieldName = 'Value',
|
String fieldName = 'Value',
|
||||||
}) {
|
}) {
|
||||||
if (value == null || value.trim().isEmpty) return null;
|
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 == null) return 'Enter a valid number';
|
||||||
if (parsed < 0) return '$fieldName cannot be negative';
|
if (parsed < 0) return '$fieldName cannot be negative';
|
||||||
return null;
|
return null;
|
||||||
@ -248,7 +270,7 @@ class Validators {
|
|||||||
String fieldName = 'Percentage',
|
String fieldName = 'Percentage',
|
||||||
}) {
|
}) {
|
||||||
if (value == null || value.trim().isEmpty) return null;
|
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 == null) return 'Enter a valid percentage';
|
||||||
if (parsed < 0 || parsed > 100) {
|
if (parsed < 0 || parsed > 100) {
|
||||||
return '$fieldName must be between 0 and 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 '../../data/repositories/asset_repository_impl.dart';
|
||||||
import '../../../../shared/models/asset_model.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).
|
/// 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 repository = ref.watch(assetRepositoryProvider);
|
||||||
final result = await repository.getCategories();
|
final result = await repository.getCategories();
|
||||||
if (result.failure != null) throw result.failure!;
|
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`).
|
/// Categories for Asset form dropdowns (`dropdown_call=true`).
|
||||||
final itemCategoriesFormProvider =
|
final itemCategoriesFormProvider =
|
||||||
FutureProvider<List<AssetCategoryModel>>((ref) async {
|
FutureProvider.autoDispose<List<AssetCategoryModel>>((ref) async {
|
||||||
final repository = ref.watch(assetRepositoryProvider);
|
final repository = ref.watch(assetRepositoryProvider);
|
||||||
final result = await repository.getCategories(dropdownCall: true);
|
final result = await repository.getCategories(dropdownCall: true);
|
||||||
if (result.failure != null) throw result.failure!;
|
if (result.failure != null) throw result.failure!;
|
||||||
return result.data ?? [];
|
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')
|
@Deprecated('Use itemCategoriesProvider')
|
||||||
final assetCategoriesProvider = itemCategoriesProvider;
|
final assetCategoriesProvider = itemCategoriesProvider;
|
||||||
|
|||||||
@ -69,7 +69,7 @@ final assetFormLookupsProvider =
|
|||||||
|
|
||||||
/// Lightweight lookups for Asset Master list filters only.
|
/// Lightweight lookups for Asset Master list filters only.
|
||||||
/// Avoids [assetFormLookupsProvider] (PO/GRN/vendors/users/options) on the list.
|
/// Avoids [assetFormLookupsProvider] (PO/GRN/vendors/users/options) on the list.
|
||||||
final assetListFilterLookupsProvider = FutureProvider<
|
final assetListFilterLookupsProvider = FutureProvider.autoDispose<
|
||||||
({
|
({
|
||||||
List<FilterOptionModel> locations,
|
List<FilterOptionModel> locations,
|
||||||
List<AssetDropdownOption> statuses,
|
List<AssetDropdownOption> statuses,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_loading_view.dart';
|
|||||||
import '../../../../shared/widgets/app_pagination.dart';
|
import '../../../../shared/widgets/app_pagination.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||||
import '../providers/assets_provider.dart';
|
import '../providers/assets_provider.dart';
|
||||||
|
|
||||||
class AssetAlertsScreen extends ConsumerStatefulWidget {
|
class AssetAlertsScreen extends ConsumerStatefulWidget {
|
||||||
@ -64,11 +65,17 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
TabBar(
|
AppSegmentedTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Expiry Alerts'),
|
AppSegmentedTab(
|
||||||
Tab(text: 'Service Alerts'),
|
label: 'Expiry Alerts',
|
||||||
|
icon: Icons.event_busy_outlined,
|
||||||
|
),
|
||||||
|
AppSegmentedTab(
|
||||||
|
label: 'Service Alerts',
|
||||||
|
icon: Icons.build_circle_outlined,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import '../../../../shared/widgets/entity_attachments_card.dart';
|
|||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
|
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||||
import '../providers/assets_provider.dart';
|
import '../providers/assets_provider.dart';
|
||||||
import '../providers/asset_form_lookups_provider.dart';
|
import '../providers/asset_form_lookups_provider.dart';
|
||||||
import 'asset_form_screen.dart';
|
import 'asset_form_screen.dart';
|
||||||
@ -122,13 +123,25 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TabBar(
|
AppSegmentedTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Overview'),
|
AppSegmentedTab(
|
||||||
Tab(text: 'AMC'),
|
label: 'Overview',
|
||||||
Tab(text: 'Service Visits'),
|
icon: Icons.dashboard_outlined,
|
||||||
Tab(text: 'Insurance'),
|
),
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
@ -350,28 +363,32 @@ class _OverviewTab extends ConsumerWidget {
|
|||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Divider(height: 1),
|
child: Divider(height: 1),
|
||||||
),
|
),
|
||||||
Align(
|
AssetRecentMaintenanceLogsSection(
|
||||||
alignment: Alignment.centerLeft,
|
assetId: assetId,
|
||||||
child: OutlinedButton.icon(
|
leadingActions: [
|
||||||
onPressed: () async {
|
OutlinedButton.icon(
|
||||||
final saved = await openSubmitMaintenancePanel(
|
onPressed: () async {
|
||||||
context,
|
final saved = await openSubmitMaintenancePanel(
|
||||||
ref,
|
|
||||||
asset: asset,
|
|
||||||
);
|
|
||||||
if (saved == true && context.mounted) {
|
|
||||||
ref.invalidate(myMaintenanceProvider);
|
|
||||||
showAppToastFromSnackBar(
|
|
||||||
context,
|
context,
|
||||||
const SnackBar(
|
ref,
|
||||||
content: Text('Maintenance log submitted'),
|
asset: asset,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
if (saved == true && context.mounted) {
|
||||||
},
|
ref.invalidate(myMaintenanceProvider);
|
||||||
icon: const Icon(Icons.checklist_outlined),
|
ref.invalidate(assetDetailProvider(assetId));
|
||||||
label: const Text('Log Maintenance'),
|
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) ...[
|
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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.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_text_field.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
import '../../../../shared/widgets/error_view.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 '../../../master_data/presentation/widgets/master_quick_add.dart';
|
||||||
import '../../data/repositories/asset_repository_impl.dart';
|
import '../../data/repositories/asset_repository_impl.dart';
|
||||||
import '../providers/asset_categories_provider.dart';
|
import '../providers/asset_categories_provider.dart';
|
||||||
@ -30,7 +30,7 @@ import '../providers/asset_form_lookups_provider.dart';
|
|||||||
import '../providers/assets_provider.dart';
|
import '../providers/assets_provider.dart';
|
||||||
|
|
||||||
/// Opens the full-page asset create/edit screen (same pattern as GRN form).
|
/// 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(
|
void openAssetForm(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref, {
|
WidgetRef ref, {
|
||||||
@ -109,6 +109,11 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
bool _isPopulatingForm = false;
|
bool _isPopulatingForm = false;
|
||||||
bool _requestedFreshLoad = false;
|
bool _requestedFreshLoad = false;
|
||||||
|
|
||||||
|
bool get _isDisposalStatus {
|
||||||
|
final status = _status?.trim().toUpperCase();
|
||||||
|
return status == 'DISPOSED' || status == 'SCRAPPED';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -185,9 +190,7 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
String key,
|
String key,
|
||||||
String value,
|
String value,
|
||||||
) {
|
) {
|
||||||
final trimmed = value.trim();
|
final parsed = CurrencyFormatter.tryParse(value);
|
||||||
if (trimmed.isEmpty) return;
|
|
||||||
final parsed = double.tryParse(trimmed);
|
|
||||||
if (parsed != null) payload[key] = parsed;
|
if (parsed != null) payload[key] = parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,20 +238,17 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
_locationDetailController.text = asset.locationDetail ?? '';
|
_locationDetailController.text = asset.locationDetail ?? '';
|
||||||
_qrCodeController.text = asset.qrCodeValue ?? '';
|
_qrCodeController.text = asset.qrCodeValue ?? '';
|
||||||
_disposalReasonController.text = asset.disposalReason ?? '';
|
_disposalReasonController.text = asset.disposalReason ?? '';
|
||||||
_disposalValueController.text = asset.disposalValue?.toString() ?? '';
|
_disposalValueController.text =
|
||||||
|
CurrencyFormatter.formatEditable(asset.disposalValue);
|
||||||
_usefulLifeController.text = asset.usefulLifeYears?.toString() ?? '';
|
_usefulLifeController.text = asset.usefulLifeYears?.toString() ?? '';
|
||||||
_depreciationRateController.text = asset.depreciationRate?.toString() ?? '';
|
_depreciationRateController.text = asset.depreciationRate?.toString() ?? '';
|
||||||
_salvageValueController.text = asset.salvageValue?.toString() ?? '';
|
_salvageValueController.text =
|
||||||
|
CurrencyFormatter.formatEditable(asset.salvageValue);
|
||||||
_remarksController.text = asset.remarks ?? '';
|
_remarksController.text = asset.remarks ?? '';
|
||||||
_frequencyController.text =
|
_frequencyController.text =
|
||||||
asset.maintenanceFrequencyInDays?.toString() ?? '';
|
asset.maintenanceFrequencyInDays?.toString() ?? '';
|
||||||
if (asset.purchaseCost != null) {
|
_costController.text =
|
||||||
_costController.text = asset.purchaseCost!.toStringAsFixed(
|
CurrencyFormatter.formatEditable(asset.purchaseCost);
|
||||||
asset.purchaseCost! % 1 == 0 ? 0 : 2,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_costController.clear();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
_isPopulatingForm = false;
|
_isPopulatingForm = false;
|
||||||
_triggerPreviewRecalculation();
|
_triggerPreviewRecalculation();
|
||||||
@ -269,7 +269,7 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final rate = double.tryParse(_depreciationRateController.text.trim());
|
final rate = CurrencyFormatter.tryParse(_depreciationRateController.text);
|
||||||
if (method == 'OTHER' && rate == null) {
|
if (method == 'OTHER' && rate == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -279,10 +279,11 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
if (rate != null) 'depreciation_rate': rate,
|
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;
|
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;
|
if (salvageValue != null) payload['salvage_value'] = salvageValue;
|
||||||
|
|
||||||
final usefulLife = int.tryParse(_usefulLifeController.text.trim());
|
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';
|
return 'Warranty expiry must be on or after purchase date';
|
||||||
}
|
}
|
||||||
|
|
||||||
final purchaseCost = double.tryParse(_costController.text.trim());
|
final purchaseCost = CurrencyFormatter.tryParse(_costController.text);
|
||||||
final salvageValue = double.tryParse(_salvageValueController.text.trim());
|
final salvageValue =
|
||||||
|
CurrencyFormatter.tryParse(_salvageValueController.text);
|
||||||
if (purchaseCost != null &&
|
if (purchaseCost != null &&
|
||||||
salvageValue != null &&
|
salvageValue != null &&
|
||||||
salvageValue > purchaseCost) {
|
salvageValue > purchaseCost) {
|
||||||
@ -530,7 +532,7 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final isDisposed = _status == 'DISPOSED' || _status == 'SCRAPPED';
|
final isDisposed = _isDisposalStatus;
|
||||||
if (isDisposed) {
|
if (isDisposed) {
|
||||||
if (_disposalDate == null) {
|
if (_disposalDate == null) {
|
||||||
return 'Disposal date is required for disposed or scrapped assets';
|
return 'Disposal date is required for disposed or scrapped assets';
|
||||||
@ -634,7 +636,7 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: EdgeInsets.zero,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final stack = constraints.maxWidth < 720;
|
final stack = constraints.maxWidth < 720;
|
||||||
@ -715,16 +717,14 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: SingleChildScrollView(
|
child: AppStickyFormLayout(
|
||||||
controller: _scrollController,
|
scrollController: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
header: _buildHeader(),
|
||||||
child: Column(
|
body: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SidePanelSection(
|
SidePanelSection(
|
||||||
title: 'BASIC DETAILS',
|
title: 'ASSET DETAILS',
|
||||||
children: [
|
children: [
|
||||||
FormRowFour(
|
FormRowFour(
|
||||||
children: [
|
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(
|
lookupsAsync.when(
|
||||||
loading: () => const Padding(
|
loading: () => const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 12),
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
@ -837,73 +776,6 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
data: (lookups) => Column(
|
data: (lookups) => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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(
|
SidePanelSection(
|
||||||
title: 'PROCUREMENT',
|
title: 'PROCUREMENT',
|
||||||
children: [
|
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(
|
SidePanelSection(
|
||||||
title: 'STATUS',
|
title: 'STATUS',
|
||||||
children: [
|
children: [
|
||||||
@ -1076,7 +1086,14 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
value: _status,
|
value: _status,
|
||||||
options: assetOptionDropdowns(statuses),
|
options: assetOptionDropdowns(statuses),
|
||||||
enabled: statuses.isNotEmpty,
|
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,
|
validator: (v) => v == null ? 'Status is required' : null,
|
||||||
),
|
),
|
||||||
AppFormToggleField(
|
AppFormToggleField(
|
||||||
@ -1089,62 +1106,61 @@ class _AssetFormScreenState extends ConsumerState<AssetFormScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SidePanelSection(
|
if (_isDisposalStatus)
|
||||||
title: 'DISPOSAL',
|
SidePanelSection(
|
||||||
children: [
|
title: 'DISPOSAL',
|
||||||
FormRowFour(
|
children: [
|
||||||
children: [
|
FormRowFour(
|
||||||
_AssetFormDateField(
|
children: [
|
||||||
label: 'Disposal Date',
|
_AssetFormDateField(
|
||||||
value: _disposalDate,
|
label: 'Disposal Date',
|
||||||
onPick: () =>
|
value: _disposalDate,
|
||||||
_pickDate((d) => _disposalDate = d, _disposalDate),
|
onPick: () =>
|
||||||
validator: () {
|
_pickDate((d) => _disposalDate = d, _disposalDate),
|
||||||
final isDisposed =
|
validator: () {
|
||||||
_status == 'DISPOSED' || _status == 'SCRAPPED';
|
if (_isDisposalStatus && _disposalDate == null) {
|
||||||
if (isDisposed && _disposalDate == null) {
|
return 'Disposal date is required';
|
||||||
return 'Disposal date is required';
|
}
|
||||||
}
|
return null;
|
||||||
return null;
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
AppTextField(
|
|
||||||
isDense: true,
|
|
||||||
controller: _disposalValueController,
|
|
||||||
label: 'Disposal Value',
|
|
||||||
keyboardType:
|
|
||||||
const TextInputType.numberWithOptions(decimal: true),
|
|
||||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
|
||||||
v,
|
|
||||||
fieldName: 'Disposal Value',
|
|
||||||
),
|
),
|
||||||
),
|
AppTextField(
|
||||||
AppTextField(
|
isDense: true,
|
||||||
isDense: true,
|
controller: _disposalValueController,
|
||||||
controller: _disposalReasonController,
|
label: 'Disposal Value',
|
||||||
label: 'Disposal Reason',
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
maxLines: 2,
|
decimal: true,
|
||||||
validator: (v) {
|
),
|
||||||
final isDisposed =
|
inputFormatters: CurrencyFormatter.amountInput,
|
||||||
_status == 'DISPOSED' || _status == 'SCRAPPED';
|
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||||
if (isDisposed) {
|
v,
|
||||||
return Validators.required(
|
fieldName: 'Disposal Value',
|
||||||
v,
|
),
|
||||||
|
),
|
||||||
|
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',
|
fieldName: 'Disposal Reason',
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
if (v == null || v.trim().isEmpty) return null;
|
),
|
||||||
return Validators.minLength(
|
],
|
||||||
v.trim(),
|
),
|
||||||
3,
|
],
|
||||||
fieldName: 'Disposal Reason',
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SidePanelSection(
|
SidePanelSection(
|
||||||
title: 'OTHER',
|
title: 'OTHER',
|
||||||
children: [
|
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) {
|
cellBuilder: (_, asset) {
|
||||||
final code = asset.assetCode;
|
final code = asset.assetCode;
|
||||||
if (code == null || code.isEmpty) return const Text('—');
|
if (code == null || code.isEmpty) return const Text('—');
|
||||||
return _AssetCodeBadge(
|
return AppTableCell.link(code, onTap: () => onView(asset));
|
||||||
code: code,
|
|
||||||
onTap: () => onView(asset),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
@ -431,7 +428,7 @@ class _AssetDataTable extends StatelessWidget {
|
|||||||
cellBuilder: (_, asset) => Text(asset.locationName ?? '—'),
|
cellBuilder: (_, asset) => Text(asset.locationName ?? '—'),
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Warranty',
|
label: 'Warranty Validity',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
searchText: (asset) =>
|
searchText: (asset) =>
|
||||||
DateFormatter.searchableDate(asset.warrantyExpiryDate),
|
DateFormatter.searchableDate(asset.warrantyExpiryDate),
|
||||||
@ -539,8 +536,8 @@ class _AssetMobileList extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
if (asset.assetCode != null && asset.assetCode!.isNotEmpty)
|
if (asset.assetCode != null && asset.assetCode!.isNotEmpty)
|
||||||
_AssetCodeBadge(
|
AppTableCell.link(
|
||||||
code: asset.assetCode!,
|
asset.assetCode!,
|
||||||
onTap: () => onView(asset),
|
onTap: () => onView(asset),
|
||||||
)
|
)
|
||||||
else
|
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();
|
DateTime _performedDate = DateTime.now();
|
||||||
late final List<_ChecklistRowState> _rows;
|
late final List<_ChecklistRowState> _rows;
|
||||||
bool _isSubmitting = false;
|
bool _isSubmitting = false;
|
||||||
bool _showLogs = false;
|
|
||||||
List<AssetMaintenanceLogModel>? _logs;
|
|
||||||
bool _logsLoading = false;
|
|
||||||
String? _logsError;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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 {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
if (_rows.isEmpty) {
|
if (_rows.isEmpty) {
|
||||||
@ -282,61 +257,7 @@ class _SubmitMaintenanceLogPanelState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
AssetRecentMaintenanceLogsSection(assetId: widget.asset.id),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -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 {
|
class _DateField extends StatelessWidget {
|
||||||
const _DateField({
|
const _DateField({
|
||||||
required this.label,
|
required this.label,
|
||||||
|
|||||||
@ -82,7 +82,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|||||||
_endDate = contract.endDate;
|
_endDate = contract.endDate;
|
||||||
_renewalDate = contract.renewalDate;
|
_renewalDate = contract.renewalDate;
|
||||||
if (contract.annualCost != null) {
|
if (contract.annualCost != null) {
|
||||||
_annualCostController.text = contract.annualCost.toString();
|
_annualCostController.text =
|
||||||
|
CurrencyFormatter.formatEditable(contract.annualCost);
|
||||||
}
|
}
|
||||||
_paymentFrequency = contract.paymentFrequency;
|
_paymentFrequency = contract.paymentFrequency;
|
||||||
_serviceFrequency = contract.serviceFrequency;
|
_serviceFrequency = contract.serviceFrequency;
|
||||||
@ -99,7 +100,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _buildPayload() {
|
Map<String, dynamic> _buildPayload() {
|
||||||
final annualCost = double.tryParse(_annualCostController.text.trim());
|
final annualCost = CurrencyFormatter.tryParse(_annualCostController.text);
|
||||||
final visitsPerYear = int.tryParse(_visitsPerYearController.text.trim());
|
final visitsPerYear = int.tryParse(_visitsPerYearController.text.trim());
|
||||||
return {
|
return {
|
||||||
'vendor_id': _vendorId,
|
'vendor_id': _vendorId,
|
||||||
@ -324,6 +325,11 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|||||||
controller: _annualCostController,
|
controller: _annualCostController,
|
||||||
label: 'Annual Cost',
|
label: 'Annual Cost',
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: CurrencyFormatter.amountInput,
|
||||||
|
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||||
|
v,
|
||||||
|
fieldName: 'Annual Cost',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@ -420,7 +426,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: true,
|
isSubmitting: true,
|
||||||
saveLabel: 'Edit AMC Contract',
|
saveLabel: 'Update AMC Contract',
|
||||||
onSave: () {},
|
onSave: () {},
|
||||||
),
|
),
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
@ -436,7 +442,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
saveLabel: 'Edit AMC Contract',
|
saveLabel: 'Update AMC Contract',
|
||||||
onSave: _save,
|
onSave: _save,
|
||||||
),
|
),
|
||||||
child: _buildForm(),
|
child: _buildForm(),
|
||||||
@ -450,7 +456,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
saveLabel: 'Add AMC Contract',
|
saveLabel: 'Save AMC Contract',
|
||||||
onSave: _save,
|
onSave: _save,
|
||||||
),
|
),
|
||||||
child: _buildForm(),
|
child: _buildForm(),
|
||||||
@ -534,7 +540,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
|||||||
_downtimeHoursController.text = visit.downtimeHours.toString();
|
_downtimeHoursController.text = visit.downtimeHours.toString();
|
||||||
}
|
}
|
||||||
if (visit.serviceCost != null) {
|
if (visit.serviceCost != null) {
|
||||||
_serviceCostController.text = visit.serviceCost.toString();
|
_serviceCostController.text =
|
||||||
|
CurrencyFormatter.formatEditable(visit.serviceCost);
|
||||||
}
|
}
|
||||||
_isUnderAmc = visit.isUnderAmc;
|
_isUnderAmc = visit.isUnderAmc;
|
||||||
_assetConditionAfter = visit.assetConditionAfter;
|
_assetConditionAfter = visit.assetConditionAfter;
|
||||||
@ -558,8 +565,10 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _buildPayload() {
|
Map<String, dynamic> _buildPayload() {
|
||||||
final downtimeHours = double.tryParse(_downtimeHoursController.text.trim());
|
final downtimeHours =
|
||||||
final serviceCost = double.tryParse(_serviceCostController.text.trim());
|
CurrencyFormatter.tryParse(_downtimeHoursController.text);
|
||||||
|
final serviceCost =
|
||||||
|
CurrencyFormatter.tryParse(_serviceCostController.text);
|
||||||
return {
|
return {
|
||||||
if (_visitType != null && _visitType!.trim().isNotEmpty) 'visit_type': _visitType,
|
if (_visitType != null && _visitType!.trim().isNotEmpty) 'visit_type': _visitType,
|
||||||
'visit_date': DateFormatter.toApiDate(_visitDate!),
|
'visit_date': DateFormatter.toApiDate(_visitDate!),
|
||||||
@ -806,6 +815,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
|||||||
controller: _serviceCostController,
|
controller: _serviceCostController,
|
||||||
label: 'Service Cost',
|
label: 'Service Cost',
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: CurrencyFormatter.amountInput,
|
||||||
validator: (v) => Validators.optionalPositiveDouble(
|
validator: (v) => Validators.optionalPositiveDouble(
|
||||||
v,
|
v,
|
||||||
fieldName: 'Service Cost',
|
fieldName: 'Service Cost',
|
||||||
@ -843,7 +853,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: true,
|
isSubmitting: true,
|
||||||
saveLabel: 'Edit Service Visit',
|
saveLabel: 'Update Service Visit',
|
||||||
onSave: () {},
|
onSave: () {},
|
||||||
),
|
),
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
@ -859,7 +869,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
saveLabel: 'Edit Service Visit',
|
saveLabel: 'Update Service Visit',
|
||||||
onSave: _save,
|
onSave: _save,
|
||||||
),
|
),
|
||||||
child: _buildForm(widget.amcContracts),
|
child: _buildForm(widget.amcContracts),
|
||||||
@ -945,10 +955,12 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
_insurerEmailController.text = policy.insurerEmail ?? '';
|
_insurerEmailController.text = policy.insurerEmail ?? '';
|
||||||
_policyType = policy.policyType;
|
_policyType = policy.policyType;
|
||||||
if (policy.sumInsured != null) {
|
if (policy.sumInsured != null) {
|
||||||
_sumInsuredController.text = policy.sumInsured.toString();
|
_sumInsuredController.text =
|
||||||
|
CurrencyFormatter.formatEditable(policy.sumInsured);
|
||||||
}
|
}
|
||||||
if (policy.annualPremium != null) {
|
if (policy.annualPremium != null) {
|
||||||
_annualPremiumController.text = policy.annualPremium.toString();
|
_annualPremiumController.text =
|
||||||
|
CurrencyFormatter.formatEditable(policy.annualPremium);
|
||||||
}
|
}
|
||||||
_startDate = policy.policyStartDate;
|
_startDate = policy.policyStartDate;
|
||||||
_endDate = policy.policyEndDate;
|
_endDate = policy.policyEndDate;
|
||||||
@ -1002,8 +1014,10 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _buildPayload() {
|
Map<String, dynamic> _buildPayload() {
|
||||||
final sumInsured = double.tryParse(_sumInsuredController.text.trim());
|
final sumInsured =
|
||||||
final annualPremium = double.tryParse(_annualPremiumController.text.trim());
|
CurrencyFormatter.tryParse(_sumInsuredController.text);
|
||||||
|
final annualPremium =
|
||||||
|
CurrencyFormatter.tryParse(_annualPremiumController.text);
|
||||||
return {
|
return {
|
||||||
'policy_no': _policyNoController.text.trim(),
|
'policy_no': _policyNoController.text.trim(),
|
||||||
'insurer_name': _insurerNameController.text.trim(),
|
'insurer_name': _insurerNameController.text.trim(),
|
||||||
@ -1141,6 +1155,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
controller: _sumInsuredController,
|
controller: _sumInsuredController,
|
||||||
label: 'Sum Insured',
|
label: 'Sum Insured',
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: CurrencyFormatter.amountInput,
|
||||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||||
v,
|
v,
|
||||||
fieldName: 'Sum Insured',
|
fieldName: 'Sum Insured',
|
||||||
@ -1152,6 +1167,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
controller: _annualPremiumController,
|
controller: _annualPremiumController,
|
||||||
label: 'Annual Premium',
|
label: 'Annual Premium',
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: CurrencyFormatter.amountInput,
|
||||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||||
v,
|
v,
|
||||||
fieldName: 'Annual Premium',
|
fieldName: 'Annual Premium',
|
||||||
@ -1240,7 +1256,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: true,
|
isSubmitting: true,
|
||||||
saveLabel: 'Edit Insurance Policy',
|
saveLabel: 'Update Insurance Policy',
|
||||||
onSave: () {},
|
onSave: () {},
|
||||||
),
|
),
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
@ -1256,7 +1272,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
saveLabel: 'Edit Insurance Policy',
|
saveLabel: 'Update Insurance Policy',
|
||||||
onSave: _save,
|
onSave: _save,
|
||||||
),
|
),
|
||||||
child: _buildForm(),
|
child: _buildForm(),
|
||||||
@ -1270,7 +1286,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
saveLabel: 'Add Insurance Policy',
|
saveLabel: 'Save Insurance Policy',
|
||||||
onSave: _save,
|
onSave: _save,
|
||||||
),
|
),
|
||||||
child: _buildForm(),
|
child: _buildForm(),
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../../../../core/config/dev_config.dart';
|
import '../../../../core/config/dev_config.dart';
|
||||||
import '../../../../core/constants/app_constants.dart';
|
|
||||||
import '../../../../core/constants/enums.dart';
|
import '../../../../core/constants/enums.dart';
|
||||||
import '../../../../core/constants/route_constants.dart';
|
import '../../../../core/constants/route_constants.dart';
|
||||||
import '../../../../core/constants/storage_keys.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(
|
return Center(
|
||||||
child: SidebarLogo(
|
child: DecoratedBox(
|
||||||
logoUrl: logoUrl,
|
decoration: BoxDecoration(
|
||||||
height: 64,
|
// Light: blend into the card. Dark: keep white so the logo stays readable.
|
||||||
width: 220,
|
color: colors.isDark ? Colors.white : colors.cardBackground,
|
||||||
fit: BoxFit.contain,
|
borderRadius: BorderRadius.circular(12),
|
||||||
showBackground: false,
|
),
|
||||||
|
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) {
|
Widget _backToSignInButton(LoginColors colors) {
|
||||||
return Center(
|
return Center(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
@ -477,117 +474,118 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}) {
|
}) {
|
||||||
return Form(
|
return Form(
|
||||||
key: _loginFormKey,
|
key: _loginFormKey,
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
_buildLogo(logoUrl),
|
children: [
|
||||||
const SizedBox(height: 20),
|
_buildLogo(logoUrl, colors),
|
||||||
Text(
|
const SizedBox(height: 20),
|
||||||
'Welcome back',
|
Text(
|
||||||
style: GoogleFonts.manrope(
|
'Welcome',
|
||||||
fontSize: 24,
|
style: GoogleFonts.manrope(
|
||||||
fontWeight: FontWeight.w800,
|
fontSize: 24,
|
||||||
letterSpacing: -0.3,
|
fontWeight: FontWeight.w800,
|
||||||
color: colors.headingColor,
|
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 5),
|
||||||
const SizedBox(height: 10),
|
Text(
|
||||||
Row(
|
'Sign in to access your BCPL workspace.',
|
||||||
children: [
|
style: GoogleFonts.inter(
|
||||||
SizedBox(
|
fontSize: 13.5,
|
||||||
height: 34,
|
color: colors.subtitleColor,
|
||||||
width: 34,
|
),
|
||||||
child: Checkbox(
|
),
|
||||||
value: _rememberMe,
|
const SizedBox(height: 26),
|
||||||
activeColor: colors.primary,
|
TextFormField(
|
||||||
checkColor: colors.onPrimary,
|
controller: _emailController,
|
||||||
side: BorderSide(color: colors.outline, width: 1.5),
|
keyboardType: TextInputType.emailAddress,
|
||||||
shape: RoundedRectangleBorder(
|
autofillHints: const [AutofillHints.email],
|
||||||
borderRadius: BorderRadius.circular(5),
|
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',
|
const SizedBox(height: 10),
|
||||||
style: GoogleFonts.inter(
|
Row(
|
||||||
fontSize: 13,
|
children: [
|
||||||
color: colors.labelColor,
|
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
const Spacer(),
|
'Remember me',
|
||||||
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(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
color: colors.labelColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const Spacer(),
|
||||||
],
|
TextButton(
|
||||||
),
|
onPressed: _flipToForgot,
|
||||||
const SizedBox(height: 14),
|
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(
|
_primaryButtonTheme(
|
||||||
colors: colors,
|
colors: colors,
|
||||||
child: AppButton(
|
child: AppButton(
|
||||||
@ -598,45 +596,44 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 26),
|
const SizedBox(height: 26),
|
||||||
_secureAccessFooter(colors),
|
_secureAccessFooter(colors),
|
||||||
const SizedBox(height: 22),
|
|
||||||
_versionLabel(colors),
|
|
||||||
if (DevConfig.screenPreviewEnabled) ...[
|
if (DevConfig.screenPreviewEnabled) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Divider(color: colors.outlineSoft),
|
Divider(color: colors.outlineSoft),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Text(
|
Text(
|
||||||
'Login API unavailable? Browse all screens without signing in:',
|
'Login API unavailable? Browse all screens without signing in:',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.subtitleColor,
|
color: colors.subtitleColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
Theme(
|
||||||
Theme(
|
data: Theme.of(context).copyWith(
|
||||||
data: Theme.of(context).copyWith(
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
style: OutlinedButton.styleFrom(
|
||||||
style: OutlinedButton.styleFrom(
|
foregroundColor: colors.primary,
|
||||||
foregroundColor: colors.primary,
|
side: BorderSide(color: colors.outlineSoft),
|
||||||
side: BorderSide(color: colors.outlineSoft),
|
minimumSize: const Size(double.infinity, 46),
|
||||||
minimumSize: const Size(double.infinity, 46),
|
shape: RoundedRectangleBorder(
|
||||||
shape: RoundedRectangleBorder(
|
borderRadius: BorderRadius.circular(12),
|
||||||
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,
|
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildLogo(logoUrl),
|
_buildLogo(logoUrl, colors),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
'Forgot Password',
|
'Forgot Password',
|
||||||
@ -724,8 +721,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_backToSignInButton(colors),
|
_backToSignInButton(colors),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_secureAccessFooter(colors),
|
_secureAccessFooter(colors),
|
||||||
const SizedBox(height: 22),
|
|
||||||
_versionLabel(colors),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -742,7 +737,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildLogo(logoUrl),
|
_buildLogo(logoUrl, colors),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
'Reset Password',
|
'Reset Password',
|
||||||
@ -801,7 +796,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (v != _newPasswordController.text) {
|
if (v != _newPasswordController.text) {
|
||||||
return 'Passwords do not match';
|
return 'Passwords do not match';
|
||||||
}
|
}
|
||||||
return Validators.required(v, fieldName: 'Confirm password');
|
return Validators.password(v);
|
||||||
},
|
},
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 14.5,
|
fontSize: 14.5,
|
||||||
@ -864,8 +859,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_backToSignInButton(colors),
|
_backToSignInButton(colors),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_secureAccessFooter(colors),
|
_secureAccessFooter(colors),
|
||||||
const SizedBox(height: 22),
|
|
||||||
_versionLabel(colors),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -944,6 +937,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (_cardHeight == null) return flipped;
|
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(
|
return SizedBox(
|
||||||
height: _cardHeight,
|
height: _cardHeight,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|||||||
@ -52,7 +52,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
obscureText: true,
|
obscureText: true,
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v != _passwordController.text) return 'Passwords do not match';
|
if (v != _passwordController.text) return 'Passwords do not match';
|
||||||
return Validators.required(v, fieldName: 'Confirm Password');
|
return Validators.password(v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@ -84,50 +84,25 @@ class _BrandMark extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Text(
|
||||||
width: 40,
|
'BCPL',
|
||||||
height: 40,
|
style: GoogleFonts.manrope(
|
||||||
alignment: Alignment.center,
|
fontWeight: FontWeight.w800,
|
||||||
decoration: BoxDecoration(
|
fontSize: 20,
|
||||||
color: colors.panelText.withValues(alpha: 0.12),
|
letterSpacing: 0.5,
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: colors.panelText,
|
||||||
border: Border.all(
|
|
||||||
color: colors.panelText.withValues(alpha: 0.18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'BC',
|
|
||||||
style: GoogleFonts.manrope(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
fontSize: 16,
|
|
||||||
color: colors.panelText,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
Text(
|
||||||
Column(
|
'BHARAT ERP',
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: GoogleFonts.inter(
|
||||||
children: [
|
fontSize: 11,
|
||||||
Text(
|
letterSpacing: 1.5,
|
||||||
'BCPL',
|
color: colors.panelTextDim,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -142,74 +117,65 @@ class _FeatureRow extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final items = [
|
final items = [
|
||||||
(
|
(Icons.layers_outlined, 'Unified data'),
|
||||||
Icons.layers_outlined,
|
(Icons.bolt_outlined, 'Real-time sync'),
|
||||||
'Unified data',
|
(Icons.bar_chart_rounded, 'Clear reporting'),
|
||||||
'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',
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return Wrap(
|
return Row(
|
||||||
spacing: 28,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
runSpacing: 16,
|
|
||||||
children: [
|
children: [
|
||||||
for (final item in items)
|
for (var i = 0; i < items.length; i++) ...[
|
||||||
SizedBox(
|
if (i > 0) const SizedBox(width: 28),
|
||||||
width: 160,
|
_FeatureItem(
|
||||||
child: Row(
|
colors: colors,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
icon: items[i].$1,
|
||||||
children: [
|
title: items[i].$2,
|
||||||
Container(
|
),
|
||||||
width: 30,
|
],
|
||||||
height: 30,
|
],
|
||||||
alignment: Alignment.center,
|
);
|
||||||
decoration: BoxDecoration(
|
}
|
||||||
color: colors.panelText.withValues(alpha: 0.10),
|
}
|
||||||
borderRadius: BorderRadius.circular(9),
|
|
||||||
border: Border.all(
|
class _FeatureItem extends StatelessWidget {
|
||||||
color: colors.panelText.withValues(alpha: 0.16),
|
const _FeatureItem({
|
||||||
),
|
required this.colors,
|
||||||
),
|
required this.icon,
|
||||||
child: Icon(item.$1, size: 15, color: colors.panelText),
|
required this.title,
|
||||||
),
|
});
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
final LoginColors colors;
|
||||||
child: Column(
|
final IconData icon;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final String title;
|
||||||
children: [
|
|
||||||
Text(
|
@override
|
||||||
item.$2,
|
Widget build(BuildContext context) {
|
||||||
style: GoogleFonts.inter(
|
return Row(
|
||||||
fontSize: 12.5,
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontWeight: FontWeight.w600,
|
children: [
|
||||||
color: colors.panelText,
|
Container(
|
||||||
),
|
width: 30,
|
||||||
),
|
height: 30,
|
||||||
const SizedBox(height: 2),
|
alignment: Alignment.center,
|
||||||
Text(
|
decoration: BoxDecoration(
|
||||||
item.$3,
|
color: colors.panelText.withValues(alpha: 0.10),
|
||||||
style: GoogleFonts.inter(
|
borderRadius: BorderRadius.circular(9),
|
||||||
fontSize: 11.5,
|
border: Border.all(
|
||||||
height: 1.4,
|
color: colors.panelText.withValues(alpha: 0.16),
|
||||||
color: colors.panelTextDim,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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'),
|
AppTextField(controller: _managerController, label: 'Manager'),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
AppButton(
|
AppButton(
|
||||||
label: isEditing ? 'Update Branch' : 'Create Branch',
|
label: isEditing ? 'Update Branch' : 'Save Branch',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_formKey.currentState!.validate()) Navigator.of(context).pop();
|
if (_formKey.currentState!.validate()) Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
|
|||||||
@ -89,7 +89,7 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
AppButton(
|
AppButton(
|
||||||
label: isEditing ? 'Update Company' : 'Create Company',
|
label: isEditing ? 'Update Company' : 'Save Company',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import '../../../../shared/widgets/app_card.dart';
|
|
||||||
import 'package:flutter/material.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 '../../../../core/utils/responsive_utils.dart';
|
||||||
import '../../../../shared/widgets/kpi_card.dart';
|
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
|
||||||
class DashboardScreen extends StatelessWidget {
|
class DashboardScreen extends StatelessWidget {
|
||||||
@ -12,83 +9,52 @@ class DashboardScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: context.contentMaxWidth),
|
constraints: BoxConstraints(maxWidth: context.contentMaxWidth),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const PageHeader(
|
const PageHeader(
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
subtitle: 'Asset management overview',
|
subtitle: 'Overview',
|
||||||
),
|
),
|
||||||
LayoutBuilder(
|
Expanded(
|
||||||
builder: (context, constraints) {
|
child: Center(
|
||||||
final crossAxisCount = constraints.maxWidth > 900 ? 3 : (constraints.maxWidth > 600 ? 2 : 1);
|
child: Column(
|
||||||
return GridView.count(
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisCount: crossAxisCount,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
mainAxisSpacing: 16,
|
|
||||||
crossAxisSpacing: 16,
|
|
||||||
childAspectRatio: 1.8,
|
|
||||||
children: [
|
children: [
|
||||||
KpiCard(
|
Image.asset(
|
||||||
title: 'Total Assets',
|
AppConstants.defaultLogoAsset,
|
||||||
value: '—',
|
height: 72,
|
||||||
icon: Icons.inventory_2_outlined,
|
fit: BoxFit.contain,
|
||||||
onTap: () => context.go(RouteConstants.assets),
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
|
Icons.dashboard_outlined,
|
||||||
|
size: 72,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
KpiCard(
|
const SizedBox(height: 24),
|
||||||
title: 'Allocated',
|
Text(
|
||||||
value: '—',
|
'Dashboard coming soon',
|
||||||
icon: Icons.assignment_ind_outlined,
|
textAlign: TextAlign.center,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
KpiCard(
|
const SizedBox(height: 8),
|
||||||
title: 'Available',
|
Text(
|
||||||
value: '—',
|
'This space is empty for now. Insights and charts will appear here later.',
|
||||||
icon: Icons.check_circle_outline,
|
textAlign: TextAlign.center,
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
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: 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_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.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_text_field.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
import '../providers/grn_lookups_provider.dart';
|
import '../providers/grn_lookups_provider.dart';
|
||||||
import '../providers/grn_provider.dart';
|
import '../providers/grn_provider.dart';
|
||||||
import '../widgets/grn_line_items_editor.dart';
|
import '../widgets/grn_line_items_editor.dart';
|
||||||
import '../widgets/grn_status_chip.dart';
|
import '../widgets/grn_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
|
||||||
|
|
||||||
class GrnFormScreen extends ConsumerStatefulWidget {
|
class GrnFormScreen extends ConsumerStatefulWidget {
|
||||||
const GrnFormScreen({super.key, this.grnId});
|
const GrnFormScreen({super.key, this.grnId});
|
||||||
@ -406,16 +407,16 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
.toList();
|
.toList();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return Form(
|
||||||
controller: _scrollController,
|
key: _formKey,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
child: AppStickyFormLayout(
|
||||||
child: Form(
|
scrollController: _scrollController,
|
||||||
key: _formKey,
|
headerPadding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
||||||
child: Column(
|
bodyPadding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||||
|
header: _buildHeader(existing),
|
||||||
|
body: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(existing),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: 'RECEIPT DETAILS',
|
title: 'RECEIPT DETAILS',
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -686,7 +687,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: EdgeInsets.zero,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final stack = constraints.maxWidth < 720;
|
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 '../../../masters/data/datasources/master_remote_data_source.dart';
|
||||||
import '../../data/repositories/master_repository_impl.dart';
|
import '../../data/repositories/master_repository_impl.dart';
|
||||||
import '../../domain/entities/master_definition.dart';
|
import '../../domain/entities/master_definition.dart';
|
||||||
|
import 'master_consumer_invalidation.dart';
|
||||||
|
|
||||||
class MasterListState {
|
class MasterListState {
|
||||||
const MasterListState({
|
const MasterListState({
|
||||||
@ -248,6 +249,7 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await refresh();
|
await refresh();
|
||||||
|
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -644,6 +646,9 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
|||||||
}
|
}
|
||||||
|
|
||||||
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
|
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 data = result.data;
|
||||||
final createdId = _readCreatedId(data);
|
final createdId = _readCreatedId(data);
|
||||||
if (createdId != null && createdId.isNotEmpty) return createdId;
|
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 '../../../../shared/widgets/page_header.dart';
|
||||||
import '../../domain/entities/master_definition.dart';
|
import '../../domain/entities/master_definition.dart';
|
||||||
import '../providers/master_provider.dart';
|
import '../providers/master_provider.dart';
|
||||||
|
import '../providers/master_consumer_invalidation.dart';
|
||||||
import '../widgets/master_form_panel.dart';
|
import '../widgets/master_form_panel.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
|
|
||||||
@ -125,7 +126,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
width: 560,
|
width: 560,
|
||||||
);
|
);
|
||||||
if (saved != null && mounted) {
|
if (saved != null && mounted) {
|
||||||
ref.invalidate(masterListProvider(widget.masterId));
|
invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
|
||||||
showAppToastFromSnackBar(context,
|
showAppToastFromSnackBar(context,
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
@ -155,6 +156,10 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id);
|
await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
|
||||||
|
}
|
||||||
|
|
||||||
showAppToastFromSnackBar(context,
|
showAppToastFromSnackBar(context,
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
|
|||||||
@ -489,7 +489,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
AppButton(
|
AppButton(
|
||||||
label: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}',
|
label:
|
||||||
|
widget.isEditing ? 'Update ${def.title}' : 'Save ${def.title}',
|
||||||
expand: false,
|
expand: false,
|
||||||
icon: Icons.check,
|
icon: Icons.check,
|
||||||
isLoading: isSubmitting,
|
isLoading: isSubmitting,
|
||||||
|
|||||||
@ -497,7 +497,7 @@ class _MasterInlineCreateFormState
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
AppButton(
|
AppButton(
|
||||||
label: 'Add ${def.title}',
|
label: 'Save ${def.title}',
|
||||||
expand: false,
|
expand: false,
|
||||||
icon: Icons.check,
|
icon: Icons.check,
|
||||||
isLoading: isSubmitting,
|
isLoading: isSubmitting,
|
||||||
|
|||||||
@ -134,11 +134,11 @@ class PurchaseOrderRemoteDataSource {
|
|||||||
|
|
||||||
Future<PurchaseOrderModel> rejectPurchaseOrder(
|
Future<PurchaseOrderModel> rejectPurchaseOrder(
|
||||||
String id, {
|
String id, {
|
||||||
required String remarks,
|
required String rejectReason,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await dio.post(
|
final response = await dio.post(
|
||||||
ApiEndpoints.purchaseOrderReject(id),
|
ApiEndpoints.purchaseOrderReject(id),
|
||||||
data: {'remarks': remarks},
|
data: {'reject_reason': rejectReason},
|
||||||
);
|
);
|
||||||
return PurchaseOrderModel.fromJson(
|
return PurchaseOrderModel.fromJson(
|
||||||
response.data['data'] as Map<String, dynamic>,
|
response.data['data'] as Map<String, dynamic>,
|
||||||
|
|||||||
@ -98,10 +98,10 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
|
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
|
||||||
String id, {
|
String id, {
|
||||||
required String remarks,
|
required String rejectReason,
|
||||||
}) {
|
}) {
|
||||||
return safeApiCall(
|
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>> approvePurchaseOrder(String id, {String? remarks});
|
||||||
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
|
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
|
||||||
String id, {
|
String id, {
|
||||||
required String remarks,
|
required String rejectReason,
|
||||||
});
|
});
|
||||||
Future<Result<PurchaseOrderModel>> amendPurchaseOrder(
|
Future<Result<PurchaseOrderModel>> amendPurchaseOrder(
|
||||||
String id, {
|
String id, {
|
||||||
|
|||||||
@ -395,9 +395,12 @@ class PurchaseOrderDetailNotifier
|
|||||||
return result.data!;
|
return result.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PurchaseOrderModel> reject({required String remarks}) async {
|
Future<PurchaseOrderModel> reject({required String rejectReason}) async {
|
||||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
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!;
|
if (result.failure != null) throw result.failure!;
|
||||||
state = AsyncData(result.data!);
|
state = AsyncData(result.data!);
|
||||||
ref.invalidate(purchaseOrdersListProvider);
|
ref.invalidate(purchaseOrdersListProvider);
|
||||||
|
|||||||
@ -109,6 +109,10 @@ class _PurchaseOrderDetailScreenState
|
|||||||
onCancel: () => _cancel(order),
|
onCancel: () => _cancel(order),
|
||||||
onDelete: _delete,
|
onDelete: _delete,
|
||||||
),
|
),
|
||||||
|
if (order.rejectReasonForDisplay != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_RejectReasonBanner(reason: order.rejectReasonForDisplay!),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_OrderDetailsCard(order: order, lookups: lookups),
|
_OrderDetailsCard(order: order, lookups: lookups),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@ -179,7 +183,9 @@ class _PurchaseOrderDetailScreenState
|
|||||||
() => ref
|
() => ref
|
||||||
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
||||||
.submit(),
|
.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 {
|
Future<void> _reject(PurchaseOrderModel order) async {
|
||||||
final remarksController = TextEditingController();
|
final reasonController = TextEditingController();
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Reject Purchase Order'),
|
title: const Text('Reject Purchase Order'),
|
||||||
content: AppTextField(
|
content: AppTextField(
|
||||||
controller: remarksController,
|
controller: reasonController,
|
||||||
label: 'Remarks *',
|
label: 'Reject reason *',
|
||||||
|
hint: 'Why is this purchase order being rejected?',
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@ -247,18 +254,19 @@ class _PurchaseOrderDetailScreenState
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed != true || !mounted) return;
|
if (confirmed != true || !mounted) return;
|
||||||
final remarks = remarksController.text.trim();
|
final rejectReason = reasonController.text.trim();
|
||||||
remarksController.dispose();
|
reasonController.dispose();
|
||||||
if (remarks.isEmpty) {
|
if (rejectReason.isEmpty) {
|
||||||
showAppToastFromSnackBar(context,
|
showAppToastFromSnackBar(
|
||||||
const SnackBar(content: Text('Rejection remarks are required')),
|
context,
|
||||||
|
const SnackBar(content: Text('Reject reason is required')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _runWorkflow(
|
await _runWorkflow(
|
||||||
() => ref
|
() => ref
|
||||||
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
||||||
.reject(remarks: remarks),
|
.reject(rejectReason: rejectReason),
|
||||||
'Purchase order rejected',
|
'Purchase order rejected',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -425,6 +433,56 @@ String _hsnLabel(
|
|||||||
return _lookupName(lookups?.hsnCodes, item.hsnCodeId);
|
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 {
|
class _DetailHeader extends StatelessWidget {
|
||||||
const _DetailHeader({
|
const _DetailHeader({
|
||||||
required this.order,
|
required this.order,
|
||||||
@ -495,7 +553,7 @@ class _DetailHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (canEdit && order.canSubmit)
|
if (canEdit && order.canSubmit)
|
||||||
_HeaderActionButton(
|
_HeaderActionButton(
|
||||||
label: 'Submit',
|
label: order.isRejected ? 'Resubmit' : 'Submit',
|
||||||
icon: Icons.send_outlined,
|
icon: Icons.send_outlined,
|
||||||
filled: true,
|
filled: true,
|
||||||
onPressed: isWorking ? null : onSubmit,
|
onPressed: isWorking ? null : onSubmit,
|
||||||
@ -556,6 +614,41 @@ class _DetailHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
PoStatusChip(status: order.status, compact: true),
|
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) ...[
|
if (order.revisionNo != null && order.revisionNo! > 0) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
PoRevisionChip(revisionNo: order.revisionNo!, compact: true),
|
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/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.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 '../../data/repositories/purchase_order_repository_impl.dart';
|
||||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||||
import '../../../master_data/presentation/widgets/master_quick_add.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 totals = _computeTotals(lookups.gstRatePctById);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return Form(
|
||||||
controller: _scrollController,
|
key: _formKey,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
child: AppStickyFormLayout(
|
||||||
child: Form(
|
scrollController: _scrollController,
|
||||||
key: _formKey,
|
headerPadding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
||||||
child: Column(
|
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,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(existing),
|
|
||||||
if (_showReapprovalWarning(existing)) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_ReapprovalBanner(),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: 'ORDER DETAILS',
|
title: 'ORDER DETAILS',
|
||||||
child: QuickAddInlineHost(
|
child: QuickAddInlineHost(
|
||||||
@ -683,7 +689,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
widget.isEditing
|
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',
|
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
@ -758,7 +764,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: EdgeInsets.zero,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final stack = constraints.maxWidth < 720;
|
final stack = constraints.maxWidth < 720;
|
||||||
|
|||||||
@ -548,11 +548,19 @@ class _PoDataTable extends StatelessWidget {
|
|||||||
label: 'Status',
|
label: 'Status',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
searchText: (order) => order.status,
|
searchText: (order) => order.status,
|
||||||
cellBuilder: (_, order) => PoStatusChip(
|
cellBuilder: (_, order) {
|
||||||
status: order.status,
|
final chip = PoStatusChip(
|
||||||
compact: true,
|
status: order.status,
|
||||||
forTable: true,
|
compact: true,
|
||||||
),
|
forTable: true,
|
||||||
|
);
|
||||||
|
final reason = order.rejectReasonForDisplay;
|
||||||
|
if (reason == null) return chip;
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Reject reason: $reason',
|
||||||
|
child: chip,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import '../../../../core/constants/route_constants.dart';
|
|||||||
import '../../../../core/errors/failure.dart';
|
import '../../../../core/errors/failure.dart';
|
||||||
import '../../../../core/theme/theme_provider.dart';
|
import '../../../../core/theme/theme_provider.dart';
|
||||||
import '../../../../core/utils/responsive_utils.dart';
|
import '../../../../core/utils/responsive_utils.dart';
|
||||||
|
import '../../../../core/utils/validators.dart';
|
||||||
import '../../../../shared/models/permission_matrix_models.dart';
|
import '../../../../shared/models/permission_matrix_models.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.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/role_form_provider.dart';
|
||||||
import '../providers/rbac_provider.dart';
|
import '../providers/rbac_provider.dart';
|
||||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||||
|
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
import '../../../roles/presentation/providers/roles_provider.dart';
|
import '../../../roles/presentation/providers/roles_provider.dart';
|
||||||
import '../widgets/add_user_panel.dart';
|
import '../widgets/add_user_panel.dart';
|
||||||
@ -395,37 +397,45 @@ class _TabBar extends ConsumerWidget {
|
|||||||
final canExport = ref.can('users', PermissionAction.export);
|
final canExport = ref.can('users', PermissionAction.export);
|
||||||
final isExporting = usersState?.isExporting ?? false;
|
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(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: AppSegmentedTabBar(
|
||||||
scrollDirection: Axis.horizontal,
|
selectedIndex: selectedIndex,
|
||||||
child: Row(
|
onChanged: (index) => onSelectTab(tabEntries[index].$1),
|
||||||
children: [
|
tabs: [for (final entry in tabEntries) entry.$2],
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isUsersTab) ...[
|
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 {
|
class _UsersTab extends ConsumerStatefulWidget {
|
||||||
const _UsersTab({
|
const _UsersTab({
|
||||||
required this.filtersExpanded,
|
required this.filtersExpanded,
|
||||||
@ -639,17 +596,22 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
|
|
||||||
Future<void> _resetPassword(ManagedUserModel user) async {
|
Future<void> _resetPassword(ManagedUserModel user) async {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
final password = await showDialog<String>(
|
final password = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Reset Password'),
|
title: const Text('Reset Password'),
|
||||||
content: TextField(
|
content: Form(
|
||||||
controller: controller,
|
key: formKey,
|
||||||
obscureText: true,
|
child: TextFormField(
|
||||||
autofocus: true,
|
controller: controller,
|
||||||
decoration: const InputDecoration(
|
obscureText: true,
|
||||||
labelText: 'New Temporary Password',
|
autofocus: true,
|
||||||
hintText: 'Min. 8 characters',
|
validator: Validators.password,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'New Temporary Password',
|
||||||
|
hintText: '8+ chars, upper, lower, digit, special',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@ -659,9 +621,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final value = controller.text.trim();
|
if (!(formKey.currentState?.validate() ?? false)) return;
|
||||||
if (value.length < 8) return;
|
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||||
Navigator.of(dialogContext).pop(value);
|
|
||||||
},
|
},
|
||||||
child: const Text('Reset'),
|
child: const Text('Reset'),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
|||||||
String? _selectedDesignationId;
|
String? _selectedDesignationId;
|
||||||
String? _selectedReportingToId;
|
String? _selectedReportingToId;
|
||||||
bool _prefilled = false;
|
bool _prefilled = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
static const _statusOptions = [
|
static const _statusOptions = [
|
||||||
AppDropdownOption(value: 'Active', label: 'Active'),
|
AppDropdownOption(value: 'Active', label: 'Active'),
|
||||||
@ -243,7 +244,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
AppButton(
|
AppButton(
|
||||||
label: widget.isEditing ? 'Update user' : 'Save user',
|
label: widget.isEditing ? 'Update User' : 'Save User',
|
||||||
expand: false,
|
expand: false,
|
||||||
icon: Icons.check,
|
icon: Icons.check,
|
||||||
isLoading: isSubmitting,
|
isLoading: isSubmitting,
|
||||||
@ -384,11 +385,25 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
|||||||
label: widget.isEditing
|
label: widget.isEditing
|
||||||
? 'New Password'
|
? 'New Password'
|
||||||
: 'Temporary Password *',
|
: 'Temporary Password *',
|
||||||
hint: 'Min. 8 characters',
|
hint: '8+ chars, upper, lower, digit, special',
|
||||||
obscureText: true,
|
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
|
validator: widget.isEditing
|
||||||
? null
|
? Validators.optionalPassword
|
||||||
: (v) => Validators.required(v, fieldName: 'Password'),
|
: Validators.password,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@ -113,7 +113,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppButton(
|
child: AppButton(
|
||||||
label: widget.isEditing ? 'Update role' : 'Create role',
|
label: widget.isEditing ? 'Update Role' : 'Save Role',
|
||||||
expand: true,
|
expand: true,
|
||||||
isLoading: isSubmitting,
|
isLoading: isSubmitting,
|
||||||
onPressed: isSubmitting ? null : _save,
|
onPressed: isSubmitting ? null : _save,
|
||||||
|
|||||||
@ -605,6 +605,7 @@ class _ScrollArrowButton extends StatelessWidget {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: icon == Icons.chevron_left ? 'Scroll left' : 'Scroll right',
|
||||||
onPressed: enabled ? onPressed : null,
|
onPressed: enabled ? onPressed : null,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
@ -272,7 +272,7 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppButton(
|
child: AppButton(
|
||||||
label: isEditing ? 'Update User' : 'Create User',
|
label: isEditing ? 'Update User' : 'Save User',
|
||||||
isLoading: _isSubmitting,
|
isLoading: _isSubmitting,
|
||||||
onPressed: _submit,
|
onPressed: _submit,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/route_constants.dart';
|
import '../../../../core/constants/route_constants.dart';
|
||||||
|
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||||
import '../../../roles/presentation/screens/role_list_screen.dart';
|
import '../../../roles/presentation/screens/role_list_screen.dart';
|
||||||
import 'user_list_screen.dart';
|
import 'user_list_screen.dart';
|
||||||
|
|
||||||
@ -33,13 +34,19 @@ class _UsersRolesHubScreenState extends State<UsersRolesHubScreen>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Material(
|
Padding(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
child: TabBar(
|
child: AppSegmentedTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Users', icon: Icon(Icons.people_outline)),
|
AppSegmentedTab(
|
||||||
Tab(text: 'Roles', icon: Icon(Icons.security_outlined)),
|
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/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||||
import '../providers/vendors_provider.dart';
|
import '../providers/vendors_provider.dart';
|
||||||
import '../widgets/vendor_form_panel.dart';
|
import '../widgets/vendor_form_panel.dart';
|
||||||
import '../widgets/vendor_sub_resource_panels.dart';
|
import '../widgets/vendor_sub_resource_panels.dart';
|
||||||
@ -116,13 +117,25 @@ class _VendorDetailScreenState extends ConsumerState<VendorDetailScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TabBar(
|
AppSegmentedTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: 'Overview'),
|
AppSegmentedTab(
|
||||||
Tab(text: 'Addresses'),
|
label: 'Overview',
|
||||||
Tab(text: 'Contacts'),
|
icon: Icons.dashboard_outlined,
|
||||||
Tab(text: 'Bank Details'),
|
),
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
@ -803,11 +816,13 @@ class _VendorAddressCard extends StatelessWidget {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (canEdit) ...[
|
if (canEdit) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
onPressed: onEdit,
|
onPressed: onEdit,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
icon: const Icon(Icons.delete_outline, size: 18),
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
onPressed: onDelete,
|
onPressed: onDelete,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
@ -968,11 +983,13 @@ class _VendorContactCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
if (canEdit) ...[
|
if (canEdit) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
onPressed: onEdit,
|
onPressed: onEdit,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
icon: const Icon(Icons.delete_outline, size: 18),
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
onPressed: onDelete,
|
onPressed: onDelete,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
@ -1146,11 +1163,13 @@ class _VendorBankDetailCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
if (canEdit) ...[
|
if (canEdit) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
onPressed: onEdit,
|
onPressed: onEdit,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
icon: const Icon(Icons.delete_outline, size: 18),
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
onPressed: onDelete,
|
onPressed: onDelete,
|
||||||
visualDensity: VisualDensity.compact,
|
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_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/error_view.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 '../../../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 '../providers/vendors_provider.dart';
|
||||||
import '../../../../shared/widgets/app_toast.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(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
saveLabel: widget.isEditing ? 'Update address' : 'Save address',
|
saveLabel: widget.isEditing ? 'Update Address' : 'Save Address',
|
||||||
onSave: _save,
|
onSave: _save,
|
||||||
),
|
),
|
||||||
child: Form(
|
child: Form(
|
||||||
@ -299,7 +299,7 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
saveLabel: widget.isEditing ? 'Update contact' : 'Save contact',
|
saveLabel: widget.isEditing ? 'Update Contact' : 'Save Contact',
|
||||||
onSave: _save,
|
onSave: _save,
|
||||||
),
|
),
|
||||||
child: Form(
|
child: Form(
|
||||||
@ -451,7 +451,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
|
|||||||
footer: _panelFooter(
|
footer: _panelFooter(
|
||||||
context,
|
context,
|
||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
saveLabel: widget.isEditing ? 'Update bank detail' : 'Save bank detail',
|
saveLabel: widget.isEditing ? 'Update Bank Detail' : 'Save Bank Detail',
|
||||||
onSave: _save,
|
onSave: _save,
|
||||||
),
|
),
|
||||||
child: Form(
|
child: Form(
|
||||||
|
|||||||
@ -171,6 +171,20 @@ Object? _readTaxTotal(Map<dynamic, dynamic> json, String key) =>
|
|||||||
Object? _readSubTotal(Map<dynamic, dynamic> json, String key) =>
|
Object? _readSubTotal(Map<dynamic, dynamic> json, String key) =>
|
||||||
_doubleFromJsonNullable(json['sub_total'] ?? json['taxable_amount']);
|
_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
|
@freezed
|
||||||
class PurchaseOrderModel with _$PurchaseOrderModel {
|
class PurchaseOrderModel with _$PurchaseOrderModel {
|
||||||
const PurchaseOrderModel._();
|
const PurchaseOrderModel._();
|
||||||
@ -209,6 +223,8 @@ class PurchaseOrderModel with _$PurchaseOrderModel {
|
|||||||
double? totalAmount,
|
double? totalAmount,
|
||||||
@JsonKey(name: 'terms_and_conditions') String? termsAndConditions,
|
@JsonKey(name: 'terms_and_conditions') String? termsAndConditions,
|
||||||
String? remarks,
|
String? remarks,
|
||||||
|
@JsonKey(name: 'reject_reason', readValue: _readRejectReason)
|
||||||
|
String? rejectReason,
|
||||||
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) int? revisionNo,
|
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) int? revisionNo,
|
||||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
|
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
|
||||||
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
|
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
|
||||||
@ -218,18 +234,21 @@ class PurchaseOrderModel with _$PurchaseOrderModel {
|
|||||||
factory PurchaseOrderModel.fromJson(Map<String, dynamic> json) =>
|
factory PurchaseOrderModel.fromJson(Map<String, dynamic> json) =>
|
||||||
_$PurchaseOrderModelFromJson(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 {
|
bool get canApprove {
|
||||||
final s = status.toUpperCase();
|
final s = status.toUpperCase();
|
||||||
return s == 'SUBMITTED' ||
|
return s == 'PENDING_APPROVAL' || s == 'SUBMITTED' || s == 'PENDING';
|
||||||
s == 'PENDING_APPROVAL' ||
|
|
||||||
s == 'PENDING' ||
|
|
||||||
s == 'REJECTED';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notify approvers via `POST /notifications/trigger` (PO_SUBMIT_APPROVAL).
|
/// Notify approvers via `POST /notifications/trigger` (PO_SUBMIT_APPROVAL).
|
||||||
@ -243,6 +262,16 @@ class PurchaseOrderModel with _$PurchaseOrderModel {
|
|||||||
final s = status.toUpperCase();
|
final s = status.toUpperCase();
|
||||||
return s != 'CANCELLED' && s != 'DRAFT';
|
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
|
@freezed
|
||||||
|
|||||||
@ -69,6 +69,8 @@ mixin _$PurchaseOrderModel {
|
|||||||
@JsonKey(name: 'terms_and_conditions')
|
@JsonKey(name: 'terms_and_conditions')
|
||||||
String? get termsAndConditions => throw _privateConstructorUsedError;
|
String? get termsAndConditions => throw _privateConstructorUsedError;
|
||||||
String? get remarks => throw _privateConstructorUsedError;
|
String? get remarks => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(name: 'reject_reason', readValue: _readRejectReason)
|
||||||
|
String? get rejectReason => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
||||||
int? get revisionNo => throw _privateConstructorUsedError;
|
int? get revisionNo => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||||
@ -132,6 +134,8 @@ abstract class $PurchaseOrderModelCopyWith<$Res> {
|
|||||||
double? totalAmount,
|
double? totalAmount,
|
||||||
@JsonKey(name: 'terms_and_conditions') String? termsAndConditions,
|
@JsonKey(name: 'terms_and_conditions') String? termsAndConditions,
|
||||||
String? remarks,
|
String? remarks,
|
||||||
|
@JsonKey(name: 'reject_reason', readValue: _readRejectReason)
|
||||||
|
String? rejectReason,
|
||||||
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
||||||
int? revisionNo,
|
int? revisionNo,
|
||||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||||
@ -182,6 +186,7 @@ class _$PurchaseOrderModelCopyWithImpl<$Res, $Val extends PurchaseOrderModel>
|
|||||||
Object? totalAmount = freezed,
|
Object? totalAmount = freezed,
|
||||||
Object? termsAndConditions = freezed,
|
Object? termsAndConditions = freezed,
|
||||||
Object? remarks = freezed,
|
Object? remarks = freezed,
|
||||||
|
Object? rejectReason = freezed,
|
||||||
Object? revisionNo = freezed,
|
Object? revisionNo = freezed,
|
||||||
Object? createdAt = freezed,
|
Object? createdAt = freezed,
|
||||||
Object? updatedAt = freezed,
|
Object? updatedAt = freezed,
|
||||||
@ -289,6 +294,10 @@ class _$PurchaseOrderModelCopyWithImpl<$Res, $Val extends PurchaseOrderModel>
|
|||||||
? _value.remarks
|
? _value.remarks
|
||||||
: remarks // ignore: cast_nullable_to_non_nullable
|
: remarks // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
|
rejectReason: freezed == rejectReason
|
||||||
|
? _value.rejectReason
|
||||||
|
: rejectReason // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
revisionNo: freezed == revisionNo
|
revisionNo: freezed == revisionNo
|
||||||
? _value.revisionNo
|
? _value.revisionNo
|
||||||
: revisionNo // ignore: cast_nullable_to_non_nullable
|
: revisionNo // ignore: cast_nullable_to_non_nullable
|
||||||
@ -358,6 +367,8 @@ abstract class _$$PurchaseOrderModelImplCopyWith<$Res>
|
|||||||
double? totalAmount,
|
double? totalAmount,
|
||||||
@JsonKey(name: 'terms_and_conditions') String? termsAndConditions,
|
@JsonKey(name: 'terms_and_conditions') String? termsAndConditions,
|
||||||
String? remarks,
|
String? remarks,
|
||||||
|
@JsonKey(name: 'reject_reason', readValue: _readRejectReason)
|
||||||
|
String? rejectReason,
|
||||||
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
||||||
int? revisionNo,
|
int? revisionNo,
|
||||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||||
@ -407,6 +418,7 @@ class __$$PurchaseOrderModelImplCopyWithImpl<$Res>
|
|||||||
Object? totalAmount = freezed,
|
Object? totalAmount = freezed,
|
||||||
Object? termsAndConditions = freezed,
|
Object? termsAndConditions = freezed,
|
||||||
Object? remarks = freezed,
|
Object? remarks = freezed,
|
||||||
|
Object? rejectReason = freezed,
|
||||||
Object? revisionNo = freezed,
|
Object? revisionNo = freezed,
|
||||||
Object? createdAt = freezed,
|
Object? createdAt = freezed,
|
||||||
Object? updatedAt = freezed,
|
Object? updatedAt = freezed,
|
||||||
@ -514,6 +526,10 @@ class __$$PurchaseOrderModelImplCopyWithImpl<$Res>
|
|||||||
? _value.remarks
|
? _value.remarks
|
||||||
: remarks // ignore: cast_nullable_to_non_nullable
|
: remarks // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
|
rejectReason: freezed == rejectReason
|
||||||
|
? _value.rejectReason
|
||||||
|
: rejectReason // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
revisionNo: freezed == revisionNo
|
revisionNo: freezed == revisionNo
|
||||||
? _value.revisionNo
|
? _value.revisionNo
|
||||||
: revisionNo // ignore: cast_nullable_to_non_nullable
|
: 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: 'grand_total', readValue: _readGrandTotal) this.totalAmount,
|
||||||
@JsonKey(name: 'terms_and_conditions') this.termsAndConditions,
|
@JsonKey(name: 'terms_and_conditions') this.termsAndConditions,
|
||||||
this.remarks,
|
this.remarks,
|
||||||
|
@JsonKey(name: 'reject_reason', readValue: _readRejectReason)
|
||||||
|
this.rejectReason,
|
||||||
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
||||||
this.revisionNo,
|
this.revisionNo,
|
||||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||||
@ -661,6 +679,9 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel {
|
|||||||
@override
|
@override
|
||||||
final String? remarks;
|
final String? remarks;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(name: 'reject_reason', readValue: _readRejectReason)
|
||||||
|
final String? rejectReason;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
||||||
final int? revisionNo;
|
final int? revisionNo;
|
||||||
@override
|
@override
|
||||||
@ -680,7 +701,7 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@ -730,6 +751,8 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel {
|
|||||||
(identical(other.termsAndConditions, termsAndConditions) ||
|
(identical(other.termsAndConditions, termsAndConditions) ||
|
||||||
other.termsAndConditions == termsAndConditions) &&
|
other.termsAndConditions == termsAndConditions) &&
|
||||||
(identical(other.remarks, remarks) || other.remarks == remarks) &&
|
(identical(other.remarks, remarks) || other.remarks == remarks) &&
|
||||||
|
(identical(other.rejectReason, rejectReason) ||
|
||||||
|
other.rejectReason == rejectReason) &&
|
||||||
(identical(other.revisionNo, revisionNo) ||
|
(identical(other.revisionNo, revisionNo) ||
|
||||||
other.revisionNo == revisionNo) &&
|
other.revisionNo == revisionNo) &&
|
||||||
(identical(other.createdAt, createdAt) ||
|
(identical(other.createdAt, createdAt) ||
|
||||||
@ -768,6 +791,7 @@ class _$PurchaseOrderModelImpl extends _PurchaseOrderModel {
|
|||||||
totalAmount,
|
totalAmount,
|
||||||
termsAndConditions,
|
termsAndConditions,
|
||||||
remarks,
|
remarks,
|
||||||
|
rejectReason,
|
||||||
revisionNo,
|
revisionNo,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@ -838,6 +862,8 @@ abstract class _PurchaseOrderModel extends PurchaseOrderModel {
|
|||||||
final double? totalAmount,
|
final double? totalAmount,
|
||||||
@JsonKey(name: 'terms_and_conditions') final String? termsAndConditions,
|
@JsonKey(name: 'terms_and_conditions') final String? termsAndConditions,
|
||||||
final String? remarks,
|
final String? remarks,
|
||||||
|
@JsonKey(name: 'reject_reason', readValue: _readRejectReason)
|
||||||
|
final String? rejectReason,
|
||||||
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
||||||
final int? revisionNo,
|
final int? revisionNo,
|
||||||
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
|
||||||
@ -925,6 +951,9 @@ abstract class _PurchaseOrderModel extends PurchaseOrderModel {
|
|||||||
@override
|
@override
|
||||||
String? get remarks;
|
String? get remarks;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(name: 'reject_reason', readValue: _readRejectReason)
|
||||||
|
String? get rejectReason;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable)
|
||||||
int? get revisionNo;
|
int? get revisionNo;
|
||||||
@override
|
@override
|
||||||
@ -956,7 +985,11 @@ mixin _$PurchaseOrderItemModel {
|
|||||||
String get id => throw _privateConstructorUsedError;
|
String get id => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'po_id', fromJson: _idFromJson)
|
@JsonKey(name: 'po_id', fromJson: _idFromJson)
|
||||||
String? get poId => throw _privateConstructorUsedError;
|
String? get poId => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'item_id',
|
||||||
|
readValue: _readItemId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
int? get itemId => throw _privateConstructorUsedError;
|
int? get itemId => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'item_code', readValue: _readItemCode)
|
@JsonKey(name: 'item_code', readValue: _readItemCode)
|
||||||
String? get itemCode => throw _privateConstructorUsedError;
|
String? get itemCode => throw _privateConstructorUsedError;
|
||||||
@ -1024,7 +1057,12 @@ abstract class $PurchaseOrderItemModelCopyWith<$Res> {
|
|||||||
$Res call({
|
$Res call({
|
||||||
@JsonKey(fromJson: _idFromJson) String id,
|
@JsonKey(fromJson: _idFromJson) String id,
|
||||||
@JsonKey(name: 'po_id', fromJson: _idFromJson) String? poId,
|
@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_code', readValue: _readItemCode) String? itemCode,
|
||||||
@JsonKey(name: 'item_name', readValue: _readItemName) String? itemName,
|
@JsonKey(name: 'item_name', readValue: _readItemName) String? itemName,
|
||||||
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo,
|
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo,
|
||||||
@ -1206,7 +1244,12 @@ abstract class _$$PurchaseOrderItemModelImplCopyWith<$Res>
|
|||||||
$Res call({
|
$Res call({
|
||||||
@JsonKey(fromJson: _idFromJson) String id,
|
@JsonKey(fromJson: _idFromJson) String id,
|
||||||
@JsonKey(name: 'po_id', fromJson: _idFromJson) String? poId,
|
@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_code', readValue: _readItemCode) String? itemCode,
|
||||||
@JsonKey(name: 'item_name', readValue: _readItemName) String? itemName,
|
@JsonKey(name: 'item_name', readValue: _readItemName) String? itemName,
|
||||||
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo,
|
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo,
|
||||||
@ -1378,7 +1421,12 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
|||||||
const _$PurchaseOrderItemModelImpl({
|
const _$PurchaseOrderItemModelImpl({
|
||||||
@JsonKey(fromJson: _idFromJson) required this.id,
|
@JsonKey(fromJson: _idFromJson) required this.id,
|
||||||
@JsonKey(name: 'po_id', fromJson: _idFromJson) this.poId,
|
@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_code', readValue: _readItemCode) this.itemCode,
|
||||||
@JsonKey(name: 'item_name', readValue: _readItemName) this.itemName,
|
@JsonKey(name: 'item_name', readValue: _readItemName) this.itemName,
|
||||||
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) this.lineNo,
|
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) this.lineNo,
|
||||||
@ -1430,7 +1478,11 @@ class _$PurchaseOrderItemModelImpl implements _PurchaseOrderItemModel {
|
|||||||
@JsonKey(name: 'po_id', fromJson: _idFromJson)
|
@JsonKey(name: 'po_id', fromJson: _idFromJson)
|
||||||
final String? poId;
|
final String? poId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'item_id',
|
||||||
|
readValue: _readItemId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
final int? itemId;
|
final int? itemId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'item_code', readValue: _readItemCode)
|
@JsonKey(name: 'item_code', readValue: _readItemCode)
|
||||||
@ -1587,7 +1639,12 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel {
|
|||||||
const factory _PurchaseOrderItemModel({
|
const factory _PurchaseOrderItemModel({
|
||||||
@JsonKey(fromJson: _idFromJson) required final String id,
|
@JsonKey(fromJson: _idFromJson) required final String id,
|
||||||
@JsonKey(name: 'po_id', fromJson: _idFromJson) final String? poId,
|
@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)
|
@JsonKey(name: 'item_code', readValue: _readItemCode)
|
||||||
final String? itemCode,
|
final String? itemCode,
|
||||||
@JsonKey(name: 'item_name', readValue: _readItemName)
|
@JsonKey(name: 'item_name', readValue: _readItemName)
|
||||||
@ -1641,7 +1698,11 @@ abstract class _PurchaseOrderItemModel implements PurchaseOrderItemModel {
|
|||||||
@JsonKey(name: 'po_id', fromJson: _idFromJson)
|
@JsonKey(name: 'po_id', fromJson: _idFromJson)
|
||||||
String? get poId;
|
String? get poId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'item_id', fromJson: _intFromJsonNullable)
|
@JsonKey(
|
||||||
|
name: 'item_id',
|
||||||
|
readValue: _readItemId,
|
||||||
|
fromJson: _intFromJsonNullable,
|
||||||
|
)
|
||||||
int? get itemId;
|
int? get itemId;
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'item_code', readValue: _readItemCode)
|
@JsonKey(name: 'item_code', readValue: _readItemCode)
|
||||||
|
|||||||
@ -34,6 +34,7 @@ _$PurchaseOrderModelImpl _$$PurchaseOrderModelImplFromJson(
|
|||||||
totalAmount: (_readGrandTotal(json, 'grand_total') as num?)?.toDouble(),
|
totalAmount: (_readGrandTotal(json, 'grand_total') as num?)?.toDouble(),
|
||||||
termsAndConditions: json['terms_and_conditions'] as String?,
|
termsAndConditions: json['terms_and_conditions'] as String?,
|
||||||
remarks: json['remarks'] as String?,
|
remarks: json['remarks'] as String?,
|
||||||
|
rejectReason: _readRejectReason(json, 'reject_reason') as String?,
|
||||||
revisionNo: _intFromJsonNullable(json['revision_no']),
|
revisionNo: _intFromJsonNullable(json['revision_no']),
|
||||||
createdAt: _dateFromJsonNullable(json['created_at']),
|
createdAt: _dateFromJsonNullable(json['created_at']),
|
||||||
updatedAt: _dateFromJsonNullable(json['updated_at']),
|
updatedAt: _dateFromJsonNullable(json['updated_at']),
|
||||||
@ -74,6 +75,7 @@ Map<String, dynamic> _$$PurchaseOrderModelImplToJson(
|
|||||||
'grand_total': instance.totalAmount,
|
'grand_total': instance.totalAmount,
|
||||||
'terms_and_conditions': instance.termsAndConditions,
|
'terms_and_conditions': instance.termsAndConditions,
|
||||||
'remarks': instance.remarks,
|
'remarks': instance.remarks,
|
||||||
|
'reject_reason': instance.rejectReason,
|
||||||
'revision_no': instance.revisionNo,
|
'revision_no': instance.revisionNo,
|
||||||
'created_at': instance.createdAt?.toIso8601String(),
|
'created_at': instance.createdAt?.toIso8601String(),
|
||||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||||
|
|||||||
@ -1,5 +1,50 @@
|
|||||||
import 'package:flutter/material.dart';
|
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).
|
/// Compact dialog date picker (avoids the full-page Material picker on web).
|
||||||
Future<DateTime?> showAppDatePopup({
|
Future<DateTime?> showAppDatePopup({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
@ -9,14 +54,20 @@ Future<DateTime?> showAppDatePopup({
|
|||||||
String helpText = 'Select date',
|
String helpText = 'Select date',
|
||||||
}) {
|
}) {
|
||||||
final now = DateTime.now();
|
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>(
|
return showDialog<DateTime>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
builder: (context) => _AppDateDialog(
|
builder: (context) => _AppDateDialog(
|
||||||
helpText: helpText,
|
helpText: helpText,
|
||||||
initialDate: initialDate ?? now,
|
initialDate: initialDate ?? now,
|
||||||
firstDate: firstDate ?? DateTime(now.year - 30),
|
firstDate: first,
|
||||||
lastDate: lastDate ?? DateTime(now.year + 10),
|
lastDate: last,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -41,6 +92,7 @@ class _AppDateDialog extends StatefulWidget {
|
|||||||
class _AppDateDialogState extends State<_AppDateDialog> {
|
class _AppDateDialogState extends State<_AppDateDialog> {
|
||||||
late DateTime _selected;
|
late DateTime _selected;
|
||||||
late DateTime _displayedMonth;
|
late DateTime _displayedMonth;
|
||||||
|
_PickerMode _mode = _PickerMode.day;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -57,23 +109,24 @@ class _AppDateDialogState extends State<_AppDateDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DateTime _clampDate(DateTime d) {
|
DateTime _clampDate(DateTime d) {
|
||||||
final first = DateTime(
|
final first = _dateOnly(widget.firstDate);
|
||||||
widget.firstDate.year,
|
final last = _dateOnly(widget.lastDate);
|
||||||
widget.firstDate.month,
|
|
||||||
widget.firstDate.day,
|
|
||||||
);
|
|
||||||
final last = DateTime(
|
|
||||||
widget.lastDate.year,
|
|
||||||
widget.lastDate.month,
|
|
||||||
widget.lastDate.day,
|
|
||||||
);
|
|
||||||
if (d.isBefore(first)) return first;
|
if (d.isBefore(first)) return first;
|
||||||
if (d.isAfter(last)) return last;
|
if (d.isAfter(last)) return last;
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _shiftMonth(int delta) {
|
void _shiftMonth(int delta) {
|
||||||
|
if (!_canShiftMonth(
|
||||||
|
_displayedMonth,
|
||||||
|
delta,
|
||||||
|
widget.firstDate,
|
||||||
|
widget.lastDate,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_mode = _PickerMode.day;
|
||||||
_displayedMonth = DateTime(
|
_displayedMonth = DateTime(
|
||||||
_displayedMonth.year,
|
_displayedMonth.year,
|
||||||
_displayedMonth.month + delta,
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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(
|
return Dialog(
|
||||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||||
@ -114,19 +232,48 @@ class _AppDateDialogState extends State<_AppDateDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_MonthHeader(
|
_MonthYearHeader(
|
||||||
month: _displayedMonth,
|
month: _displayedMonth,
|
||||||
|
mode: _mode,
|
||||||
|
canPrev: canPrev && _mode == _PickerMode.day,
|
||||||
|
canNext: canNext && _mode == _PickerMode.day,
|
||||||
onPrev: () => _shiftMonth(-1),
|
onPrev: () => _shiftMonth(-1),
|
||||||
onNext: () => _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),
|
const SizedBox(height: 8),
|
||||||
_CalendarGrid(
|
switch (_mode) {
|
||||||
month: _displayedMonth,
|
_PickerMode.year => _YearPickerGrid(
|
||||||
firstDate: widget.firstDate,
|
selectedYear: _displayedMonth.year,
|
||||||
lastDate: widget.lastDate,
|
firstYear: widget.firstDate.year,
|
||||||
selected: _selected,
|
lastYear: widget.lastDate.year,
|
||||||
onSelected: (day) => setState(() => _selected = _clampDate(day)),
|
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),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@ -137,7 +284,9 @@ class _AppDateDialogState extends State<_AppDateDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(context).pop(_selected),
|
onPressed: _canApply
|
||||||
|
? () => Navigator.of(context).pop(_selected)
|
||||||
|
: null,
|
||||||
child: const Text('Apply'),
|
child: const Text('Apply'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -150,54 +299,58 @@ class _AppDateDialogState extends State<_AppDateDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MonthHeader extends StatelessWidget {
|
class _MonthYearHeader extends StatelessWidget {
|
||||||
const _MonthHeader({
|
const _MonthYearHeader({
|
||||||
required this.month,
|
required this.month,
|
||||||
|
required this.mode,
|
||||||
|
required this.canPrev,
|
||||||
|
required this.canNext,
|
||||||
required this.onPrev,
|
required this.onPrev,
|
||||||
required this.onNext,
|
required this.onNext,
|
||||||
|
required this.onMonthTap,
|
||||||
|
required this.onYearTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DateTime month;
|
final DateTime month;
|
||||||
|
final _PickerMode mode;
|
||||||
|
final bool canPrev;
|
||||||
|
final bool canNext;
|
||||||
final VoidCallback onPrev;
|
final VoidCallback onPrev;
|
||||||
final VoidCallback onNext;
|
final VoidCallback onNext;
|
||||||
|
final VoidCallback onMonthTap;
|
||||||
|
final VoidCallback onYearTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onPrev,
|
tooltip: 'Previous month',
|
||||||
|
onPressed: canPrev ? onPrev : null,
|
||||||
icon: const Icon(Icons.chevron_left),
|
icon: const Icon(Icons.chevron_left),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Row(
|
||||||
title,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
_HeaderChip(
|
||||||
fontWeight: FontWeight.w600,
|
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(
|
IconButton(
|
||||||
onPressed: onNext,
|
tooltip: 'Next month',
|
||||||
|
onPressed: canNext ? onNext : null,
|
||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
visualDensity: VisualDensity.compact,
|
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 {
|
class _CalendarGrid extends StatelessWidget {
|
||||||
const _CalendarGrid({
|
const _CalendarGrid({
|
||||||
required this.month,
|
required this.month,
|
||||||
@ -221,6 +558,10 @@ class _CalendarGrid extends StatelessWidget {
|
|||||||
final DateTime selected;
|
final DateTime selected;
|
||||||
final ValueChanged<DateTime> onSelected;
|
final ValueChanged<DateTime> onSelected;
|
||||||
|
|
||||||
|
static const int _weeks = 6;
|
||||||
|
static const double _dayExtent = 40;
|
||||||
|
static const double _spacing = 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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 daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||||
final leading = firstOfMonth.weekday % 7;
|
final leading = firstOfMonth.weekday % 7;
|
||||||
const weekdays = ['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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@ -249,54 +593,54 @@ class _CalendarGrid extends StatelessWidget {
|
|||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
GridView.builder(
|
SizedBox(
|
||||||
shrinkWrap: true,
|
height: gridHeight,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: GridView.builder(
|
||||||
itemCount: leading + daysInMonth,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
itemCount: 7 * _weeks,
|
||||||
crossAxisCount: 7,
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
mainAxisSpacing: 4,
|
crossAxisCount: 7,
|
||||||
crossAxisSpacing: 4,
|
mainAxisSpacing: _spacing,
|
||||||
),
|
crossAxisSpacing: _spacing,
|
||||||
itemBuilder: (context, index) {
|
mainAxisExtent: _dayExtent,
|
||||||
if (index < leading) return const SizedBox.shrink();
|
),
|
||||||
final day = index - leading + 1;
|
itemBuilder: (context, index) {
|
||||||
final date = DateTime(month.year, month.month, day);
|
if (index < leading || index >= leading + daysInMonth) {
|
||||||
final enabled = !date.isBefore(
|
return const SizedBox.shrink();
|
||||||
DateTime(firstDate.year, firstDate.month, firstDate.day),
|
}
|
||||||
) &&
|
final day = index - leading + 1;
|
||||||
!date.isAfter(
|
final date = DateTime(month.year, month.month, day);
|
||||||
DateTime(lastDate.year, lastDate.month, lastDate.day),
|
final enabled = !date.isBefore(first) && !date.isAfter(last);
|
||||||
);
|
final isSelected = date.year == selected.year &&
|
||||||
final isSelected = date.year == selected.year &&
|
date.month == selected.month &&
|
||||||
date.month == selected.month &&
|
date.day == selected.day;
|
||||||
date.day == selected.day;
|
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? theme.colorScheme.primary
|
? theme.colorScheme.primary
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
shape: const CircleBorder(),
|
shape: const CircleBorder(),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
onTap: enabled ? () => onSelected(date) : null,
|
onTap: enabled ? () => onSelected(date) : null,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'$day',
|
'$day',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: !enabled
|
color: !enabled
|
||||||
? theme.disabledColor
|
? theme.disabledColor
|
||||||
: isSelected
|
: isSelected
|
||||||
? theme.colorScheme.onPrimary
|
? theme.colorScheme.onPrimary
|
||||||
: theme.colorScheme.onSurface,
|
: theme.colorScheme.onSurface,
|
||||||
fontWeight:
|
fontWeight:
|
||||||
isSelected ? FontWeight.w700 : FontWeight.w500,
|
isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,51 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../core/utils/formatters.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).
|
/// Compact dialog date-range picker (avoids the full-page Material picker on web).
|
||||||
Future<DateTimeRange?> showAppDateRangePopup({
|
Future<DateTimeRange?> showAppDateRangePopup({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
@ -11,14 +56,20 @@ Future<DateTimeRange?> showAppDateRangePopup({
|
|||||||
String helpText = 'Select date range',
|
String helpText = 'Select date range',
|
||||||
}) {
|
}) {
|
||||||
final now = DateTime.now();
|
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>(
|
return showDialog<DateTimeRange>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
builder: (context) => _AppDateRangeDialog(
|
builder: (context) => _AppDateRangeDialog(
|
||||||
helpText: helpText,
|
helpText: helpText,
|
||||||
initialDateRange: initialDateRange,
|
initialDateRange: initialDateRange,
|
||||||
firstDate: firstDate ?? DateTime(now.year - 5),
|
firstDate: first,
|
||||||
lastDate: lastDate ?? DateTime(now.year + 1),
|
lastDate: last,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -44,20 +95,83 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> {
|
|||||||
DateTime? _start;
|
DateTime? _start;
|
||||||
DateTime? _end;
|
DateTime? _end;
|
||||||
late DateTime _displayedMonth;
|
late DateTime _displayedMonth;
|
||||||
|
_PickerMode _mode = _PickerMode.day;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_start = widget.initialDateRange?.start;
|
_start = widget.initialDateRange?.start;
|
||||||
_end = widget.initialDateRange?.end;
|
_end = widget.initialDateRange?.end;
|
||||||
|
if (_start != null && _end != null && _end!.isBefore(_start!)) {
|
||||||
|
final swap = _start;
|
||||||
|
_start = _end;
|
||||||
|
_end = swap;
|
||||||
|
}
|
||||||
_displayedMonth = DateTime(
|
_displayedMonth = DateTime(
|
||||||
(_start ?? DateTime.now()).year,
|
(_start ?? DateTime.now()).year,
|
||||||
(_start ?? DateTime.now()).month,
|
(_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) {
|
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(() {
|
setState(() {
|
||||||
if (_start == null || (_start != null && _end != null)) {
|
if (_start == null || (_start != null && _end != null)) {
|
||||||
_start = selected;
|
_start = selected;
|
||||||
@ -73,28 +187,37 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> {
|
|||||||
|
|
||||||
bool _isInRange(DateTime day) {
|
bool _isInRange(DateTime day) {
|
||||||
if (_start == null || _end == null) return false;
|
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!);
|
return !d.isBefore(_start!) && !d.isAfter(_end!);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isEndpoint(DateTime day) {
|
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);
|
return (_start != null && d == _start) || (_end != null && d == _end);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _shiftMonth(int delta) {
|
bool get _canApply =>
|
||||||
setState(() {
|
_start != null &&
|
||||||
_displayedMonth = DateTime(
|
_end != null &&
|
||||||
_displayedMonth.year,
|
!_end!.isBefore(_start!) &&
|
||||||
_displayedMonth.month + delta,
|
!_start!.isBefore(_dateOnly(widget.firstDate)) &&
|
||||||
);
|
!_end!.isAfter(_dateOnly(widget.lastDate));
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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
|
final rangeLabel = _start == null
|
||||||
? 'Select start date'
|
? 'Select start date'
|
||||||
: _end == null
|
: _end == null
|
||||||
@ -137,20 +260,48 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_MonthHeader(
|
_MonthYearHeader(
|
||||||
month: _displayedMonth,
|
month: _displayedMonth,
|
||||||
|
mode: _mode,
|
||||||
|
canPrev: canPrev && _mode == _PickerMode.day,
|
||||||
|
canNext: canNext && _mode == _PickerMode.day,
|
||||||
onPrev: () => _shiftMonth(-1),
|
onPrev: () => _shiftMonth(-1),
|
||||||
onNext: () => _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),
|
const SizedBox(height: 8),
|
||||||
_CalendarGrid(
|
switch (_mode) {
|
||||||
month: _displayedMonth,
|
_PickerMode.year => _YearPickerGrid(
|
||||||
firstDate: widget.firstDate,
|
selectedYear: _displayedMonth.year,
|
||||||
lastDate: widget.lastDate,
|
firstYear: widget.firstDate.year,
|
||||||
isInRange: _isInRange,
|
lastYear: widget.lastDate.year,
|
||||||
isEndpoint: _isEndpoint,
|
onSelected: _selectYear,
|
||||||
onSelected: _onDaySelected,
|
),
|
||||||
),
|
_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),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@ -168,7 +319,7 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: canApply
|
onPressed: _canApply
|
||||||
? () => Navigator.of(context).pop(
|
? () => Navigator.of(context).pop(
|
||||||
DateTimeRange(start: _start!, end: _end!),
|
DateTimeRange(start: _start!, end: _end!),
|
||||||
)
|
)
|
||||||
@ -185,54 +336,58 @@ class _AppDateRangeDialogState extends State<_AppDateRangeDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MonthHeader extends StatelessWidget {
|
class _MonthYearHeader extends StatelessWidget {
|
||||||
const _MonthHeader({
|
const _MonthYearHeader({
|
||||||
required this.month,
|
required this.month,
|
||||||
|
required this.mode,
|
||||||
|
required this.canPrev,
|
||||||
|
required this.canNext,
|
||||||
required this.onPrev,
|
required this.onPrev,
|
||||||
required this.onNext,
|
required this.onNext,
|
||||||
|
required this.onMonthTap,
|
||||||
|
required this.onYearTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DateTime month;
|
final DateTime month;
|
||||||
|
final _PickerMode mode;
|
||||||
|
final bool canPrev;
|
||||||
|
final bool canNext;
|
||||||
final VoidCallback onPrev;
|
final VoidCallback onPrev;
|
||||||
final VoidCallback onNext;
|
final VoidCallback onNext;
|
||||||
|
final VoidCallback onMonthTap;
|
||||||
|
final VoidCallback onYearTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onPrev,
|
tooltip: 'Previous month',
|
||||||
|
onPressed: canPrev ? onPrev : null,
|
||||||
icon: const Icon(Icons.chevron_left),
|
icon: const Icon(Icons.chevron_left),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Row(
|
||||||
title,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
_HeaderChip(
|
||||||
fontWeight: FontWeight.w600,
|
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(
|
IconButton(
|
||||||
onPressed: onNext,
|
tooltip: 'Next month',
|
||||||
|
onPressed: canNext ? onNext : null,
|
||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
visualDensity: VisualDensity.compact,
|
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 {
|
class _CalendarGrid extends StatelessWidget {
|
||||||
const _CalendarGrid({
|
const _CalendarGrid({
|
||||||
required this.month,
|
required this.month,
|
||||||
@ -258,15 +597,20 @@ class _CalendarGrid extends StatelessWidget {
|
|||||||
final bool Function(DateTime day) isEndpoint;
|
final bool Function(DateTime day) isEndpoint;
|
||||||
final ValueChanged<DateTime> onSelected;
|
final ValueChanged<DateTime> onSelected;
|
||||||
|
|
||||||
|
static const int _weeks = 6;
|
||||||
|
static const double _dayExtent = 40;
|
||||||
|
static const double _spacing = 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final firstOfMonth = DateTime(month.year, month.month);
|
final firstOfMonth = DateTime(month.year, month.month);
|
||||||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
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 leading = firstOfMonth.weekday % 7;
|
||||||
|
const weekdays = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||||
final weekdays = const ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
final gridHeight = _weeks * _dayExtent + (_weeks - 1) * _spacing;
|
||||||
|
final first = _dateOnly(firstDate);
|
||||||
|
final last = _dateOnly(lastDate);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@ -288,54 +632,55 @@ class _CalendarGrid extends StatelessWidget {
|
|||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
GridView.builder(
|
SizedBox(
|
||||||
shrinkWrap: true,
|
height: gridHeight,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: GridView.builder(
|
||||||
itemCount: leading + daysInMonth,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
itemCount: 7 * _weeks,
|
||||||
crossAxisCount: 7,
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
mainAxisSpacing: 4,
|
crossAxisCount: 7,
|
||||||
crossAxisSpacing: 4,
|
mainAxisSpacing: _spacing,
|
||||||
),
|
crossAxisSpacing: _spacing,
|
||||||
itemBuilder: (context, index) {
|
mainAxisExtent: _dayExtent,
|
||||||
if (index < leading) return const SizedBox.shrink();
|
),
|
||||||
final day = index - leading + 1;
|
itemBuilder: (context, index) {
|
||||||
final date = DateTime(month.year, month.month, day);
|
if (index < leading || index >= leading + daysInMonth) {
|
||||||
final enabled = !date.isBefore(
|
return const SizedBox.shrink();
|
||||||
DateTime(firstDate.year, firstDate.month, firstDate.day),
|
}
|
||||||
) &&
|
final day = index - leading + 1;
|
||||||
!date.isAfter(
|
final date = DateTime(month.year, month.month, day);
|
||||||
DateTime(lastDate.year, lastDate.month, lastDate.day),
|
final enabled = !date.isBefore(first) && !date.isAfter(last);
|
||||||
);
|
final endpoint = isEndpoint(date);
|
||||||
final endpoint = isEndpoint(date);
|
final inRange = isInRange(date);
|
||||||
final inRange = isInRange(date);
|
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: endpoint
|
color: endpoint
|
||||||
? theme.colorScheme.primary
|
? theme.colorScheme.primary
|
||||||
: inRange
|
: inRange
|
||||||
? theme.colorScheme.primary.withValues(alpha: 0.12)
|
? theme.colorScheme.primary.withValues(alpha: 0.12)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
shape: const CircleBorder(),
|
shape: const CircleBorder(),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
onTap: enabled ? () => onSelected(date) : null,
|
onTap: enabled ? () => onSelected(date) : null,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'$day',
|
'$day',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: !enabled
|
color: !enabled
|
||||||
? theme.disabledColor
|
? theme.disabledColor
|
||||||
: endpoint
|
: endpoint
|
||||||
? theme.colorScheme.onPrimary
|
? theme.colorScheme.onPrimary
|
||||||
: theme.colorScheme.onSurface,
|
: theme.colorScheme.onSurface,
|
||||||
fontWeight: endpoint ? FontWeight.w700 : FontWeight.w500,
|
fontWeight:
|
||||||
|
endpoint ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class AppSearchField extends StatelessWidget {
|
|||||||
isDense: true,
|
isDense: true,
|
||||||
suffixIcon: controller.text.isNotEmpty
|
suffixIcon: controller.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
|
tooltip: 'Clear',
|
||||||
icon: const Icon(Icons.clear, size: 18),
|
icon: const Icon(Icons.clear, size: 18),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
controller.clear();
|
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),
|
duration: const Duration(milliseconds: 180),
|
||||||
opacity: blocked ? 0.45 : 1,
|
opacity: blocked ? 0.45 : 1,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
|
tooltip: 'Close',
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () => _close(context),
|
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,
|
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