bharat_erp/lib/modules/assets/presentation/widgets/asset_side_panels.dart

1859 lines
60 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/user_management_models.dart'
show FilterOptionModel;
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart';
class AddAmcPanel extends ConsumerStatefulWidget {
const AddAmcPanel({
super.key,
required this.assetId,
this.contractId,
});
final String assetId;
final String? contractId;
bool get isEdit => contractId != null;
@override
ConsumerState<AddAmcPanel> createState() => _AddAmcPanelState();
}
class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
final _formKey = GlobalKey<FormState>();
final _contractNoController = TextEditingController();
final _annualCostController = TextEditingController();
final _visitsPerYearController = TextEditingController();
final _contactPersonController = TextEditingController();
final _contactPhoneController = TextEditingController();
final _contactEmailController = TextEditingController();
final _scopeOfWorkController = TextEditingController();
final _exclusionsController = TextEditingController();
final _remarksController = TextEditingController();
DateTime? _startDate;
DateTime? _endDate;
DateTime? _renewalDate;
int? _vendorId;
String? _contractType;
String? _paymentFrequency;
String? _serviceFrequency;
bool _isActive = true;
bool _isSubmitting = false;
String? _populatedContractId;
@override
void dispose() {
_contractNoController.dispose();
_annualCostController.dispose();
_visitsPerYearController.dispose();
_contactPersonController.dispose();
_contactPhoneController.dispose();
_contactEmailController.dispose();
_scopeOfWorkController.dispose();
_exclusionsController.dispose();
_remarksController.dispose();
super.dispose();
}
void _populateFromContract(AmcContractModel contract) {
if (_populatedContractId == contract.id) return;
_populatedContractId = contract.id;
_vendorId = contract.vendorId;
_contractNoController.text = contract.contractNo ?? '';
_contractType = contract.contractType;
_startDate = contract.startDate;
_endDate = contract.endDate;
_renewalDate = contract.renewalDate;
if (contract.annualCost != null) {
_annualCostController.text =
CurrencyFormatter.formatEditable(contract.annualCost);
}
_paymentFrequency = contract.paymentFrequency;
_serviceFrequency = contract.serviceFrequency;
if (contract.visitsPerYear != null) {
_visitsPerYearController.text = contract.visitsPerYear.toString();
}
_contactPersonController.text = contract.contactPerson ?? '';
_contactPhoneController.text = contract.contactPhone ?? '';
_contactEmailController.text = contract.contactEmail ?? '';
_scopeOfWorkController.text = contract.scopeOfWork ?? '';
_exclusionsController.text = contract.exclusions ?? '';
_remarksController.text = contract.remarks ?? '';
_isActive = contract.isActive;
}
Map<String, dynamic> _buildPayload() {
final annualCost = CurrencyFormatter.tryParse(_annualCostController.text);
final visitsPerYear = int.tryParse(_visitsPerYearController.text.trim());
return {
'vendor_id': _vendorId,
if (_contractNoController.text.isNotEmpty)
'contract_no': _contractNoController.text.trim(),
if (_contractType != null && _contractType!.trim().isNotEmpty)
'contract_type': _contractType,
'start_date': DateFormatter.toApiDate(_startDate!),
'end_date': DateFormatter.toApiDate(_endDate!),
if (_renewalDate != null) 'renewal_date': DateFormatter.toApiDate(_renewalDate!),
if (annualCost != null) 'annual_cost': annualCost,
if (_paymentFrequency != null && _paymentFrequency!.trim().isNotEmpty)
'payment_frequency': _paymentFrequency,
if (_serviceFrequency != null && _serviceFrequency!.trim().isNotEmpty)
'service_frequency': _serviceFrequency,
if (visitsPerYear != null) 'visits_per_year': visitsPerYear,
if (_contactPersonController.text.trim().isNotEmpty)
'contact_person': _contactPersonController.text.trim(),
if (_contactPhoneController.text.trim().isNotEmpty)
'contact_phone': _contactPhoneController.text.trim(),
if (_contactEmailController.text.trim().isNotEmpty)
'contact_email': _contactEmailController.text.trim(),
if (_scopeOfWorkController.text.trim().isNotEmpty)
'scope_of_work': _scopeOfWorkController.text.trim(),
if (_exclusionsController.text.trim().isNotEmpty)
'exclusions': _exclusionsController.text.trim(),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
'is_active': _isActive,
};
}
Future<void> _pickDate({
required DateTime? current,
required void Function(DateTime date) onPicked,
DateTime? firstDate,
DateTime? lastDate,
}) async {
final first = firstDate ?? DateTime(2000);
final last = lastDate ?? DateTime(2100);
var initial = current ?? DateTime.now();
if (initial.isBefore(first)) initial = first;
if (initial.isAfter(last)) initial = last;
final picked = await showAppDatePopup(
context: context,
initialDate: initial,
firstDate: first,
lastDate: last,
helpText: 'Select date',
);
if (picked != null) {
setState(() => onPicked(picked));
}
}
void _onStartDatePicked(DateTime date) {
final clearedEnd = _endDate != null &&
DateTime(_endDate!.year, _endDate!.month, _endDate!.day)
.isBefore(DateTime(date.year, date.month, date.day));
_startDate = date;
if (clearedEnd) {
_endDate = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showSidePanelSnackBar(
context,
'End date cleared. Please select an end date on or after the start date.',
);
});
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_startDate == null || _endDate == null) {
showSidePanelSnackBar(context, 'Please select start and end dates');
return;
}
if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore(
DateTime(_startDate!.year, _startDate!.month, _startDate!.day),
)) {
showSidePanelSnackBar(
context,
'End date cannot be earlier than start date',
);
return;
}
if (_vendorId == null) {
showSidePanelSnackBar(context, 'Please select a vendor');
return;
}
setState(() => _isSubmitting = true);
try {
final payload = _buildPayload();
final notifier = ref.read(assetDetailProvider(widget.assetId).notifier);
if (widget.isEdit) {
await notifier.updateAmc(widget.contractId!, payload);
} else {
await notifier.createAmc(payload);
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
showSidePanelApiError(context, e);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
Widget _buildForm() {
final lookupsAsync = ref.watch(assetFormLookupsProvider);
final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
if (lookupsAsync.hasValue) {
final nextContractType = resolveAssetOptionValue(
_contractType,
options.contractTypes,
preferFirstWhenEmpty: !widget.isEdit,
);
final nextPaymentFrequency = resolveAssetOptionValue(
_paymentFrequency,
options.paymentFrequencies,
preferFirstWhenEmpty: !widget.isEdit,
);
final nextServiceFrequency = resolveAssetOptionValue(
_serviceFrequency,
options.serviceFrequencies,
preferFirstWhenEmpty: !widget.isEdit,
);
if (nextContractType != _contractType ||
nextPaymentFrequency != _paymentFrequency ||
nextServiceFrequency != _serviceFrequency) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_contractType = nextContractType;
_paymentFrequency = nextPaymentFrequency;
_serviceFrequency = nextServiceFrequency;
});
});
}
}
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidePanelFormRow(
left: lookupsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load vendors'),
data: (lookups) => AppSearchableDropdown<int>(
label: 'Vendor *',
value: _vendorId,
searchHint: 'Search vendor...',
options: lookups.vendors
.map((vendor) => AppDropdownOption(
value: int.tryParse(vendor.id) ?? 0,
label: vendor.name,
))
.where((option) => option.value != 0)
.toList(),
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) => v == null ? 'Vendor is required' : null,
),
),
right: AppTextField(
controller: _contractNoController,
label: 'Contract No',
),
),
AppSearchableDropdown<String>(
label: 'Contract Type',
value: _contractType,
searchHint: 'Search contract type...',
options: assetOptionDropdowns(options.contractTypes),
enabled: options.contractTypes.isNotEmpty,
onChanged: (v) {
if (v != null) setState(() => _contractType = v);
},
validator: (v) =>
options.contractTypes.isEmpty ? null : (v == null ? 'Contract type is required' : null),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: _SidePanelDateField(
label: 'Start Date',
isRequired: true,
value: _startDate,
onPick: () => _pickDate(
current: _startDate,
onPicked: _onStartDatePicked,
),
),
right: _SidePanelDateField(
label: 'End Date',
isRequired: true,
value: _endDate,
onPick: () => _pickDate(
current: _endDate,
firstDate: _startDate ?? DateTime(2000),
onPicked: (date) => _endDate = date,
),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: _SidePanelDateField(
label: 'Renewal Date',
value: _renewalDate,
onPick: () => _pickDate(
current: _renewalDate,
onPicked: (date) => _renewalDate = date,
),
),
right: AppTextField(
controller: _annualCostController,
label: 'Annual Cost',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: CurrencyFormatter.amountInput,
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Annual Cost',
),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppSearchableDropdown<String>(
label: 'Payment Frequency',
value: _paymentFrequency,
searchHint: 'Search payment frequency...',
options: assetOptionDropdowns(options.paymentFrequencies),
enabled: options.paymentFrequencies.isNotEmpty,
onChanged: (v) => setState(() => _paymentFrequency = v),
),
right: AppSearchableDropdown<String>(
label: 'Service Frequency',
value: _serviceFrequency,
searchHint: 'Search service frequency...',
options: assetOptionDropdowns(options.serviceFrequencies),
enabled: options.serviceFrequencies.isNotEmpty,
onChanged: (v) => setState(() => _serviceFrequency = v),
),
),
const SizedBox(height: 12),
AppTextField(
controller: _visitsPerYearController,
label: 'Visits Per Year',
keyboardType: TextInputType.number,
validator: (v) => Validators.optionalPositiveInt(
v,
fieldName: 'Visits Per Year',
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _contactPersonController,
label: 'Contact Person',
),
right: AppTextField(
controller: _contactPhoneController,
label: 'Contact Phone',
keyboardType: TextInputType.phone,
validator: Validators.optionalMobile,
inputFormatters: Validators.mobileInput,
),
),
const SizedBox(height: 12),
AppTextField(
controller: _contactEmailController,
label: 'Contact Email',
keyboardType: TextInputType.emailAddress,
validator: Validators.optionalEmail,
),
const SizedBox(height: 12),
AppTextField(
controller: _scopeOfWorkController,
label: 'Scope Of Work',
maxLines: 3,
),
const SizedBox(height: 12),
AppTextField(
controller: _exclusionsController,
label: 'Exclusions',
maxLines: 3,
),
const SizedBox(height: 12),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 3,
),
const SizedBox(height: 8),
AppFormToggleField(
label: 'Active',
value: _isActive,
onChanged: (value) => setState(() => _isActive = value),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (widget.isEdit) {
final contractAsync = ref.watch(
amcContractFormProvider((
assetId: widget.assetId,
contractId: widget.contractId!,
)),
);
return contractAsync.when(
loading: () => SidePanelScaffold(
title: 'Edit AMC Contract',
footer: _panelFooter(
context,
isSubmitting: true,
saveLabel: 'Update AMC Contract',
onSave: () {},
),
child: const Center(child: CircularProgressIndicator()),
),
error: (error, _) => SidePanelScaffold(
title: 'Edit AMC Contract',
child: Center(child: Text(error.toString())),
),
data: (contract) {
_populateFromContract(contract);
return SidePanelScaffold(
title: 'Edit AMC Contract',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Update AMC Contract',
onSave: _save,
),
child: _buildForm(),
);
},
);
}
return SidePanelScaffold(
title: 'Add AMC Contract',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Save AMC Contract',
onSave: _save,
),
child: _buildForm(),
);
}
}
class LogServiceVisitPanel extends ConsumerStatefulWidget {
const LogServiceVisitPanel({
super.key,
required this.assetId,
this.visitId,
this.amcContracts = const [],
});
final String assetId;
final String? visitId;
final List<AmcContractModel> amcContracts;
bool get isEdit => visitId != null;
@override
ConsumerState<LogServiceVisitPanel> createState() => _LogServiceVisitPanelState();
}
class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
final _formKey = GlobalKey<FormState>();
final _complaintNoController = TextEditingController();
final _complaintDescController = TextEditingController();
final _engineerNameController = TextEditingController();
final _engineerPhoneController = TextEditingController();
final _workDoneController = TextEditingController();
final _partsReplacedController = TextEditingController();
final _downtimeHoursController = TextEditingController();
final _serviceCostController = TextEditingController();
final _remarksController = TextEditingController();
DateTime? _visitDate = DateTime.now();
DateTime? _complaintDate;
DateTime? _nextServiceDate;
int? _amcContractId;
int? _vendorId;
String? _visitType;
String? _status;
String? _assetConditionAfter;
bool _isUnderAmc = false;
bool _isSubmitting = false;
String? _populatedVisitId;
@override
void dispose() {
_complaintNoController.dispose();
_complaintDescController.dispose();
_engineerNameController.dispose();
_engineerPhoneController.dispose();
_workDoneController.dispose();
_partsReplacedController.dispose();
_downtimeHoursController.dispose();
_serviceCostController.dispose();
_remarksController.dispose();
super.dispose();
}
void _populateFromVisit(ServiceVisitModel visit) {
if (_populatedVisitId == visit.id) return;
_populatedVisitId = visit.id;
_visitType = visit.visitType;
_visitDate = visit.visitDate;
_amcContractId = visit.amcContractId;
_complaintNoController.text = visit.complaintNo ?? '';
_complaintDate = visit.complaintDate;
_complaintDescController.text = visit.complaintDesc ?? '';
_engineerNameController.text = visit.engineerName ?? '';
_engineerPhoneController.text = visit.engineerPhone ?? '';
_vendorId = visit.vendorId;
_workDoneController.text = visit.workDone ?? '';
_partsReplacedController.text = visit.partsReplaced ?? '';
_nextServiceDate = visit.nextServiceDate;
_status = visit.status;
if (visit.downtimeHours != null) {
_downtimeHoursController.text = visit.downtimeHours.toString();
}
if (visit.serviceCost != null) {
_serviceCostController.text =
CurrencyFormatter.formatEditable(visit.serviceCost);
}
_isUnderAmc = visit.isUnderAmc || visit.amcContractId != null;
_assetConditionAfter = visit.assetConditionAfter;
_remarksController.text = visit.remarks ?? '';
}
Future<void> _pickDate({
required DateTime? current,
required void Function(DateTime date) onPicked,
}) async {
final picked = await showAppDatePopup(
context: context,
initialDate: current ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
helpText: 'Select date',
);
if (picked != null) {
setState(() => onPicked(picked));
}
}
Map<String, dynamic> _buildPayload() {
final downtimeHours =
CurrencyFormatter.tryParse(_downtimeHoursController.text);
final serviceCost =
CurrencyFormatter.tryParse(_serviceCostController.text);
return {
if (_visitType != null && _visitType!.trim().isNotEmpty)
'visit_type': _visitType,
'visit_date': DateFormatter.toApiDate(_visitDate!),
if (_isUnderAmc && _amcContractId != null)
'amc_contract_id': _amcContractId,
if (_complaintNoController.text.trim().isNotEmpty)
'complaint_no': _complaintNoController.text.trim(),
if (_complaintDate != null)
'complaint_date': DateFormatter.toApiDate(_complaintDate!),
if (_complaintDescController.text.trim().isNotEmpty)
'complaint_desc': _complaintDescController.text.trim(),
if (_engineerNameController.text.trim().isNotEmpty)
'engineer_name': _engineerNameController.text.trim(),
if (_engineerPhoneController.text.trim().isNotEmpty)
'engineer_phone': _engineerPhoneController.text.trim(),
if (_vendorId != null) 'vendor_id': _vendorId,
if (_workDoneController.text.trim().isNotEmpty)
'work_done': _workDoneController.text.trim(),
if (_partsReplacedController.text.trim().isNotEmpty)
'parts_replaced': _partsReplacedController.text.trim(),
if (_nextServiceDate != null)
'next_service_date': DateFormatter.toApiDate(_nextServiceDate!),
if (_status != null && _status!.trim().isNotEmpty) 'status': _status,
if (downtimeHours != null) 'downtime_hours': downtimeHours,
if (serviceCost != null) 'service_cost': serviceCost,
'is_under_amc': _isUnderAmc,
if (_assetConditionAfter != null &&
_assetConditionAfter!.trim().isNotEmpty)
'asset_condition_after': _assetConditionAfter,
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
};
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_visitDate == null) {
showSidePanelSnackBar(context, 'Please select visit date');
return;
}
setState(() => _isSubmitting = true);
try {
final payload = _buildPayload();
final notifier = ref.read(assetDetailProvider(widget.assetId).notifier);
if (widget.isEdit) {
await notifier.updateVisit(widget.visitId!, payload);
} else {
await notifier.logVisit(payload);
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
showSidePanelApiError(context, e);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
void _setUnderAmc(bool value) {
setState(() {
_isUnderAmc = value;
if (!value) {
_amcContractId = null;
}
});
}
void _onAmcContractChanged(
int? contractId,
List<AmcContractModel> amcContracts,
) {
setState(() {
_amcContractId = contractId;
if (contractId == null) return;
final contract = amcContracts
.where((c) => int.tryParse(c.id) == contractId)
.firstOrNull;
if (contract?.vendorId != null) {
_vendorId = contract!.vendorId;
}
});
}
List<AppDropdownOption<int>> _vendorOptions({
required List<FilterOptionModel> vendors,
required List<AmcContractModel> amcContracts,
}) {
final all = vendors
.map(
(vendor) => AppDropdownOption(
value: int.tryParse(vendor.id) ?? 0,
label: vendor.name,
),
)
.where((option) => option.value != 0)
.toList();
if (!_isUnderAmc || _amcContractId == null) return all;
final contract = amcContracts
.where((c) => int.tryParse(c.id) == _amcContractId)
.firstOrNull;
final contractVendorId = contract?.vendorId;
if (contractVendorId == null) return all;
return all.where((option) => option.value == contractVendorId).toList();
}
Widget _buildForm(List<AmcContractModel> amcContracts) {
final lookupsAsync = ref.watch(assetFormLookupsProvider);
final options =
lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
if (lookupsAsync.hasValue) {
final nextVisitType = resolveAssetOptionValue(
_visitType,
options.visitTypes,
preferFirstWhenEmpty: !widget.isEdit,
);
final nextStatus = resolveAssetOptionValue(
_status,
options.visitStatuses,
preferFirstWhenEmpty: !widget.isEdit,
);
final nextConditionAfter = resolveAssetOptionValue(
_assetConditionAfter,
options.visitConditionsAfter,
preferFirstWhenEmpty: !widget.isEdit,
);
if (nextVisitType != _visitType ||
nextStatus != _status ||
nextConditionAfter != _assetConditionAfter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_visitType = nextVisitType;
_status = nextStatus;
_assetConditionAfter = nextConditionAfter;
});
});
}
}
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1) Visit classification
SidePanelFormRow(
left: AppSearchableDropdown<String>(
label: 'Visit Type *',
value: _visitType,
searchHint: 'Search visit type...',
options: assetOptionDropdowns(options.visitTypes),
enabled: options.visitTypes.isNotEmpty,
onChanged: (v) {
if (v != null) setState(() => _visitType = v);
},
validator: (v) => v == null ? 'Visit type is required' : null,
),
right: AppSearchableDropdown<String>(
label: 'Status',
value: _status,
searchHint: 'Search status...',
options: assetOptionDropdowns(options.visitStatuses),
enabled: options.visitStatuses.isNotEmpty,
onChanged: (v) {
if (v != null) setState(() => _status = v);
},
),
),
_SidePanelDateField(
label: 'Visit Date',
isRequired: true,
value: _visitDate,
onPick: () => _pickDate(
current: _visitDate,
onPicked: (date) => setState(() => _visitDate = date),
),
),
const SizedBox(height: 8),
// 2) Contract & Vendor
AppFormToggleField(
label: 'Under AMC',
value: _isUnderAmc,
onChanged: _setUnderAmc,
),
if (_isUnderAmc) ...[
const SizedBox(height: 8),
AppSearchableDropdown<int>(
label: 'AMC Contract',
value: _amcContractId,
searchHint: 'Search AMC contract...',
options: amcContracts
.map((contract) {
final id = int.tryParse(contract.id);
if (id == null) return null;
final label =
contract.contractNo?.trim().isNotEmpty == true
? contract.contractNo!
: 'AMC #${contract.id}';
return AppDropdownOption(value: id, label: label);
})
.whereType<AppDropdownOption<int>>()
.toList(),
onChanged: (v) => _onAmcContractChanged(v, amcContracts),
),
],
const SizedBox(height: 12),
SidePanelFormRow(
left: lookupsAsync.when(
loading: () => const Padding(
padding: EdgeInsets.only(top: 8),
child: LinearProgressIndicator(),
),
error: (_, __) => const Text('Failed to load vendors'),
data: (lookups) => AppSearchableDropdown<int>(
label: 'Vendor',
value: _vendorId,
searchHint: 'Search vendor...',
options: _vendorOptions(
vendors: lookups.vendors,
amcContracts: amcContracts,
),
onChanged: (v) => setState(() => _vendorId = v),
),
),
right: AppSearchableDropdown<String>(
label: 'Asset Condition After',
value: _assetConditionAfter,
searchHint: 'Search condition...',
options: assetOptionDropdowns(options.visitConditionsAfter),
enabled: options.visitConditionsAfter.isNotEmpty,
onChanged: (v) => setState(() => _assetConditionAfter = v),
),
),
// 3) Complaint intake
SidePanelFormRow(
left: AppTextField(
controller: _complaintNoController,
label: 'Complaint No',
),
right: _SidePanelDateField(
label: 'Complaint Date',
value: _complaintDate,
onPick: () => _pickDate(
current: _complaintDate,
onPicked: (date) => setState(() => _complaintDate = date),
),
),
),
AppTextField(
controller: _complaintDescController,
label: 'Complaint Description',
maxLines: 3,
),
const SizedBox(height: 12),
// 4) Engineer & work performed
SidePanelFormRow(
left: AppTextField(
controller: _engineerNameController,
label: 'Engineer Name',
),
right: AppTextField(
controller: _engineerPhoneController,
label: 'Engineer Phone',
keyboardType: TextInputType.phone,
validator: Validators.optionalMobile,
inputFormatters: Validators.mobileInput,
),
),
AppTextField(
controller: _workDoneController,
label: 'Work Done',
maxLines: 3,
),
const SizedBox(height: 12),
AppTextField(
controller: _partsReplacedController,
label: 'Parts Replaced',
maxLines: 3,
),
const SizedBox(height: 12),
// 5) Outcome & cost
SidePanelFormRow(
left: _SidePanelDateField(
label: 'Next Service Date',
value: _nextServiceDate,
onPick: () => _pickDate(
current: _nextServiceDate,
onPicked: (date) => setState(() => _nextServiceDate = date),
),
),
right: AppTextField(
controller: _downtimeHoursController,
label: 'Downtime Hours',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalPositiveDouble(
v,
fieldName: 'Downtime Hours',
),
),
),
AppTextField(
controller: _serviceCostController,
label: 'Service Cost',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: CurrencyFormatter.amountInput,
validator: (v) => Validators.optionalPositiveDouble(
v,
fieldName: 'Service Cost',
),
),
const SizedBox(height: 12),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 3,
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (widget.isEdit) {
final visitAsync = ref.watch(
serviceVisitFormProvider((
assetId: widget.assetId,
visitId: widget.visitId!,
)),
);
return visitAsync.when(
loading: () => SidePanelScaffold(
title: 'Edit Service Visit',
footer: _panelFooter(
context,
isSubmitting: true,
saveLabel: 'Update Service Visit',
onSave: () {},
),
child: const Center(child: CircularProgressIndicator()),
),
error: (error, _) => SidePanelScaffold(
title: 'Edit Service Visit',
child: Center(child: Text(error.toString())),
),
data: (visit) {
_populateFromVisit(visit);
return SidePanelScaffold(
title: 'Edit Service Visit',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Update Service Visit',
onSave: _save,
),
child: _buildForm(widget.amcContracts),
);
},
);
}
return SidePanelScaffold(
title: 'Log Service Visit',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Log Service Visit',
onSave: _save,
),
child: _buildForm(widget.amcContracts),
);
}
}
class AddInsurancePanel extends ConsumerStatefulWidget {
const AddInsurancePanel({
super.key,
required this.assetId,
this.policyId,
});
final String assetId;
final String? policyId;
bool get isEdit => policyId != null;
@override
ConsumerState<AddInsurancePanel> createState() => _AddInsurancePanelState();
}
class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
final _formKey = GlobalKey<FormState>();
final _policyNoController = TextEditingController();
final _insurerNameController = TextEditingController();
final _insurerBranchController = TextEditingController();
final _insurerContactController = TextEditingController();
final _insurerPhoneController = TextEditingController();
final _insurerEmailController = TextEditingController();
final _sumInsuredController = TextEditingController();
final _annualPremiumController = TextEditingController();
final _remarksController = TextEditingController();
DateTime? _startDate;
DateTime? _endDate;
DateTime? _renewalDate;
DateTime? _premiumPaidDate;
String? _policyType;
bool _isAutoRenewal = false;
bool _premiumPaid = false;
bool _isActive = true;
bool _isSubmitting = false;
String? _populatedPolicyId;
@override
void dispose() {
_policyNoController.dispose();
_insurerNameController.dispose();
_insurerBranchController.dispose();
_insurerContactController.dispose();
_insurerPhoneController.dispose();
_insurerEmailController.dispose();
_sumInsuredController.dispose();
_annualPremiumController.dispose();
_remarksController.dispose();
super.dispose();
}
void _populateFromPolicy(InsurancePolicyModel policy) {
if (_populatedPolicyId == policy.id) return;
_populatedPolicyId = policy.id;
_policyNoController.text = policy.policyNo;
_insurerNameController.text = policy.insurerName;
_insurerBranchController.text = policy.insurerBranch ?? '';
_insurerContactController.text = policy.insurerContact ?? '';
_insurerPhoneController.text = policy.insurerPhone ?? '';
_insurerEmailController.text = policy.insurerEmail ?? '';
_policyType = policy.policyType;
if (policy.sumInsured != null) {
_sumInsuredController.text =
CurrencyFormatter.formatEditable(policy.sumInsured);
}
if (policy.annualPremium != null) {
_annualPremiumController.text =
CurrencyFormatter.formatEditable(policy.annualPremium);
}
_startDate = policy.policyStartDate;
_endDate = policy.policyEndDate;
_renewalDate = policy.renewalDate;
_isAutoRenewal = policy.isAutoRenewal;
_premiumPaid = policy.premiumPaid;
_premiumPaidDate = policy.premiumPaidDate;
_remarksController.text = policy.remarks ?? '';
_isActive = policy.isActive;
}
Future<void> _pickDate({
required DateTime? current,
required void Function(DateTime date) onPicked,
DateTime? firstDate,
DateTime? lastDate,
}) async {
final first = firstDate ?? DateTime(2000);
final last = lastDate ?? DateTime(2100);
var initial = current ?? DateTime.now();
if (initial.isBefore(first)) initial = first;
if (initial.isAfter(last)) initial = last;
final picked = await showAppDatePopup(
context: context,
initialDate: initial,
firstDate: first,
lastDate: last,
helpText: 'Select date',
);
if (picked != null) {
setState(() => onPicked(picked));
}
}
void _onStartDatePicked(DateTime date) {
final clearedEnd = _endDate != null &&
DateTime(_endDate!.year, _endDate!.month, _endDate!.day)
.isBefore(DateTime(date.year, date.month, date.day));
_startDate = date;
if (clearedEnd) {
_endDate = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showSidePanelSnackBar(
context,
'End date cleared. Please select an end date on or after the start date.',
);
});
}
}
Map<String, dynamic> _buildPayload() {
final sumInsured =
CurrencyFormatter.tryParse(_sumInsuredController.text);
final annualPremium =
CurrencyFormatter.tryParse(_annualPremiumController.text);
return {
'policy_no': _policyNoController.text.trim(),
'insurer_name': _insurerNameController.text.trim(),
if (_insurerBranchController.text.trim().isNotEmpty)
'insurer_branch': _insurerBranchController.text.trim(),
if (_insurerContactController.text.trim().isNotEmpty)
'insurer_contact': _insurerContactController.text.trim(),
if (_insurerPhoneController.text.trim().isNotEmpty)
'insurer_phone': _insurerPhoneController.text.trim(),
if (_insurerEmailController.text.trim().isNotEmpty)
'insurer_email': _insurerEmailController.text.trim(),
if (_policyType != null && _policyType!.trim().isNotEmpty)
'policy_type': _policyType,
if (sumInsured != null) 'sum_insured': sumInsured,
if (annualPremium != null) 'annual_premium': annualPremium,
'policy_start_date': DateFormatter.toApiDate(_startDate!),
'policy_end_date': DateFormatter.toApiDate(_endDate!),
if (_renewalDate != null) 'renewal_date': DateFormatter.toApiDate(_renewalDate!),
'is_auto_renewal': _isAutoRenewal,
'premium_paid': _premiumPaid,
if (_premiumPaidDate != null)
'premium_paid_date': DateFormatter.toApiDate(_premiumPaidDate!),
if (_remarksController.text.trim().isNotEmpty) 'remarks': _remarksController.text.trim(),
'is_active': _isActive,
};
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_startDate == null || _endDate == null) {
showSidePanelSnackBar(context, 'Please select start and end dates');
return;
}
if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore(
DateTime(_startDate!.year, _startDate!.month, _startDate!.day),
)) {
showSidePanelSnackBar(
context,
'End date cannot be earlier than start date',
);
return;
}
setState(() => _isSubmitting = true);
try {
final payload = _buildPayload();
final notifier = ref.read(assetDetailProvider(widget.assetId).notifier);
if (widget.isEdit) {
await notifier.updateInsurance(widget.policyId!, payload);
} else {
await notifier.createInsurance(payload);
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
showSidePanelApiError(context, e);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
Widget _buildForm() {
final lookupsAsync = ref.watch(assetFormLookupsProvider);
final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
if (lookupsAsync.hasValue) {
final nextPolicyType = resolveAssetOptionValue(
_policyType,
options.policyTypes,
preferFirstWhenEmpty: !widget.isEdit,
);
if (nextPolicyType != _policyType) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _policyType = nextPolicyType);
});
}
}
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidePanelFormRow(
left: AppTextField(
controller: _policyNoController,
label: 'Policy No *',
validator: (v) => Validators.required(v, fieldName: 'Policy No'),
),
right: AppTextField(
controller: _insurerNameController,
label: 'Insurer Name *',
validator: (v) => Validators.required(v, fieldName: 'Insurer Name'),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _insurerBranchController,
label: 'Insurer Branch',
),
right: AppTextField(
controller: _insurerContactController,
label: 'Insurer Contact',
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _insurerPhoneController,
label: 'Insurer Phone',
),
right: AppTextField(
controller: _insurerEmailController,
label: 'Insurer Email',
keyboardType: TextInputType.emailAddress,
validator: Validators.optionalEmail,
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppSearchableDropdown<String>(
label: 'Policy Type',
value: _policyType,
searchHint: 'Search policy type...',
options: assetOptionDropdowns(options.policyTypes),
enabled: options.policyTypes.isNotEmpty,
onChanged: (v) {
if (v != null) setState(() => _policyType = v);
},
),
right: AppTextField(
controller: _sumInsuredController,
label: 'Sum Insured',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: CurrencyFormatter.amountInput,
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Sum Insured',
),
),
),
const SizedBox(height: 12),
AppTextField(
controller: _annualPremiumController,
label: 'Annual Premium',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: CurrencyFormatter.amountInput,
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Annual Premium',
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: _SidePanelDateField(
label: 'Start Date',
isRequired: true,
value: _startDate,
onPick: () => _pickDate(
current: _startDate,
onPicked: _onStartDatePicked,
),
),
right: _SidePanelDateField(
label: 'End Date',
isRequired: true,
value: _endDate,
onPick: () => _pickDate(
current: _endDate,
firstDate: _startDate ?? DateTime(2000),
onPicked: (date) => _endDate = date,
),
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: _SidePanelDateField(
label: 'Renewal Date',
value: _renewalDate,
onPick: () => _pickDate(
current: _renewalDate,
onPicked: (date) => _renewalDate = date,
),
),
right: _SidePanelDateField(
label: 'Premium Paid Date',
value: _premiumPaidDate,
onPick: () => _pickDate(
current: _premiumPaidDate,
onPicked: (date) => _premiumPaidDate = date,
),
),
),
const SizedBox(height: 8),
AppFormToggleField(
label: 'Auto Renewal',
value: _isAutoRenewal,
onChanged: (value) => setState(() => _isAutoRenewal = value),
),
AppFormToggleField(
label: 'Premium Paid',
value: _premiumPaid,
onChanged: (value) => setState(() => _premiumPaid = value),
),
AppFormToggleField(
label: 'Active',
value: _isActive,
onChanged: (value) => setState(() => _isActive = value),
),
const SizedBox(height: 12),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 3,
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (widget.isEdit) {
final policyAsync = ref.watch(
insurancePolicyFormProvider((
assetId: widget.assetId,
policyId: widget.policyId!,
)),
);
return policyAsync.when(
loading: () => SidePanelScaffold(
title: 'Edit Insurance Policy',
footer: _panelFooter(
context,
isSubmitting: true,
saveLabel: 'Update Insurance Policy',
onSave: () {},
),
child: const Center(child: CircularProgressIndicator()),
),
error: (error, _) => SidePanelScaffold(
title: 'Edit Insurance Policy',
child: Center(child: Text(error.toString())),
),
data: (policy) {
_populateFromPolicy(policy);
return SidePanelScaffold(
title: 'Edit Insurance Policy',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Update Insurance Policy',
onSave: _save,
),
child: _buildForm(),
);
},
);
}
return SidePanelScaffold(
title: 'Add Insurance Policy',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Save Insurance Policy',
onSave: _save,
),
child: _buildForm(),
);
}
}
class TransferAssetPanel extends ConsumerStatefulWidget {
const TransferAssetPanel({super.key, required this.assetId});
final String assetId;
@override
ConsumerState<TransferAssetPanel> createState() => _TransferAssetPanelState();
}
class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
final _formKey = GlobalKey<FormState>();
final _reasonController = TextEditingController();
DateTime _transferDate = DateTime.now();
int? _toLocationId;
int? _toUserId;
bool _isSubmitting = false;
@override
void dispose() {
_reasonController.dispose();
super.dispose();
}
Future<void> _pickDate() async {
final picked = await showAppDatePopup(
context: context,
initialDate: _transferDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
helpText: 'Transfer date',
);
if (picked != null) {
setState(() => _transferDate = picked);
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final hasDestination = _toLocationId != null || _toUserId != null;
if (!hasDestination) {
showSidePanelSnackBar(
context,
'Choose at least one destination field',
);
return;
}
setState(() => _isSubmitting = true);
try {
await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({
'transfer_date': DateFormatter.toApiDate(_transferDate),
if (_toLocationId != null) 'to_location_id': _toLocationId,
if (_toUserId != null) 'to_user_id': _toUserId,
'reason': _reasonController.text.trim(),
});
if (mounted) {
Navigator.of(context, rootNavigator: true).pop(true);
}
} catch (e) {
if (mounted) {
showSidePanelApiError(context, e);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
final lookupsAsync = ref.watch(assetFormLookupsProvider);
return SidePanelScaffold(
title: 'Transfer Asset',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Transfer',
onSave: _save,
),
child: lookupsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text(error.toString())),
data: (lookups) => Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SidePanelDateField(
label: 'Transfer Date',
isRequired: true,
value: _transferDate,
onPick: _pickDate,
),
const SizedBox(height: 12),
AppSearchableDropdown<int>(
label: 'To Location',
value: _toLocationId,
searchHint: 'Search plant or warehouse...',
options: lookups.locations
.map((option) {
final id = int.tryParse(option.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: option.name);
})
.whereType<AppDropdownOption<int>>()
.toList(),
onChanged: (v) => setState(() => _toLocationId = v),
),
const SizedBox(height: 12),
AppSearchableDropdown<int>(
label: 'To User',
value: _toUserId,
searchHint: 'Search user...',
options: lookups.users
.map((option) {
final id = int.tryParse(option.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: option.name);
})
.whereType<AppDropdownOption<int>>()
.toList(),
onChanged: (v) => setState(() => _toUserId = v),
),
const SizedBox(height: 12),
AppTextField(
controller: _reasonController,
label: 'Reason *',
maxLines: 3,
validator: (v) => Validators.required(v, fieldName: 'Reason'),
),
],
),
),
),
);
}
}
class _TransferHistoryEntry extends StatelessWidget {
const _TransferHistoryEntry({required this.item});
final AssetTransferHistoryModel item;
DateTime? _resolvedTransferDateTime() {
final transferDate = item.transferDate?.toLocal();
final createdAt = item.createdAt?.toLocal();
if (transferDate == null) return createdAt;
if (createdAt == null) return transferDate;
return DateTime(
transferDate.year,
transferDate.month,
transferDate.day,
createdAt.hour,
createdAt.minute,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final resolvedDateTime = _resolvedTransferDateTime();
final transferDate = resolvedDateTime != null
? DateFormatter.displayDateTime(resolvedDateTime)
: '';
final reason = item.reason?.trim();
final showReason = reason != null && reason.isNotEmpty && reason.toLowerCase() != 'nil';
return AppCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.swap_horiz_rounded,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
transferDate,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 16),
_TransferFromToGrid(
fromLocation: item.fromLocationName,
fromUser: item.fromUserName,
toLocation: item.toLocationName,
toUser: item.toUserName,
),
if (showReason) ...[
const SizedBox(height: 16),
Divider(
height: 1,
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
const SizedBox(height: 12),
Text(
'Reason',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
reason,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
);
}
}
class _TransferFromToGrid extends StatelessWidget {
const _TransferFromToGrid({
this.fromLocation,
this.fromUser,
this.toLocation,
this.toUser,
});
final String? fromLocation;
final String? fromUser;
final String? toLocation;
final String? toUser;
static const _arrowWidth = 34.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text('From', style: labelStyle)),
const SizedBox(width: _arrowWidth),
Expanded(child: Text('To', style: labelStyle)),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _TransferLocationRow(
icon: Icons.place_outlined,
value: fromLocation,
),
),
SizedBox(
width: _arrowWidth,
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
Icons.arrow_forward_rounded,
size: 18,
color: theme.colorScheme.outline,
),
),
),
Expanded(
child: _TransferLocationRow(
icon: Icons.place_outlined,
value: toLocation,
),
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _TransferLocationRow(
icon: Icons.person_outline,
value: fromUser,
),
),
const SizedBox(width: _arrowWidth),
Expanded(
child: _TransferLocationRow(
icon: Icons.person_outline,
value: toUser,
),
),
],
),
],
);
}
}
class _TransferLocationRow extends StatelessWidget {
const _TransferLocationRow({
required this.icon,
this.value,
});
final IconData icon;
final String? value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final display = value?.trim().isNotEmpty == true ? value!.trim() : '';
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
display,
style: theme.textTheme.bodySmall?.copyWith(
color: display == ''
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
),
),
),
],
),
);
}
}
class TransferHistoryPanel extends ConsumerWidget {
const TransferHistoryPanel({super.key, required this.assetId});
final String assetId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyAsync = ref.watch(transferHistoryProvider(assetId));
return SidePanelScaffold(
title: 'Transfer History',
child: historyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(
message: error is Failure
? error.message
: 'Unable to load transfer history',
onRetry: () => ref.invalidate(transferHistoryProvider(assetId)),
),
data: (history) {
if (history.isEmpty) {
return const AppEmptyState(
title: 'No transfer history',
description: 'Transfers for this asset will appear here.',
icon: Icons.swap_horiz_outlined,
);
}
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${history.length} transfer${history.length == 1 ? '' : 's'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
for (var index = 0; index < history.length; index++) ...[
if (index > 0) const SizedBox(height: 12),
_TransferHistoryEntry(item: history[index]),
],
],
);
},
),
);
}
}
Widget _panelFooter(
BuildContext context, {
required bool isSubmitting,
required String saveLabel,
required VoidCallback onSave,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: isSubmitting
? null
: () => Navigator.of(context, rootNavigator: true).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: saveLabel,
expand: false,
icon: Icons.check,
isLoading: isSubmitting,
onPressed: isSubmitting ? null : onSave,
),
],
);
}
class _SidePanelDateField extends StatefulWidget {
const _SidePanelDateField({
required this.label,
required this.value,
required this.onPick,
this.isRequired = false,
});
final String label;
final DateTime? value;
final VoidCallback onPick;
final bool isRequired;
@override
State<_SidePanelDateField> createState() => _SidePanelDateFieldState();
}
class _SidePanelDateFieldState extends State<_SidePanelDateField> {
late final TextEditingController _controller;
String get _displayText =>
widget.value != null ? DateFormatter.displayDate(widget.value) : '';
String get _labelText =>
widget.isRequired ? '${widget.label} *' : widget.label;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: _displayText);
}
@override
void didUpdateWidget(covariant _SidePanelDateField oldWidget) {
super.didUpdateWidget(oldWidget);
final text = _displayText;
if (_controller.text != text) {
_controller.text = text;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
readOnly: true,
onTap: widget.onPick,
controller: _controller,
decoration: InputDecoration(
labelText: _labelText,
hintText: 'Select date',
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20),
),
),
);
}
}