676 lines
21 KiB
Dart
676 lines
21 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../../core/constants/app_constants.dart';
|
|
import '../../../../core/utils/column_search_paging.dart';
|
|
import '../../../../core/utils/pagination_meta.dart';
|
|
import '../../../../core/utils/table_search.dart';
|
|
import '../../../../shared/models/export_file_result.dart';
|
|
import '../../../assets/data/repositories/asset_repository_impl.dart';
|
|
import '../../../masters/data/datasources/master_remote_data_source.dart';
|
|
import '../../data/repositories/master_repository_impl.dart';
|
|
import '../../domain/entities/master_definition.dart';
|
|
import 'master_consumer_invalidation.dart';
|
|
|
|
class MasterListState {
|
|
const MasterListState({
|
|
this.items = const [],
|
|
this.search = '',
|
|
this.page = 1,
|
|
this.limit = 20,
|
|
this.total = 0,
|
|
this.totalPages = 1,
|
|
this.isDeleting = false,
|
|
this.isExporting = false,
|
|
this.actionError,
|
|
});
|
|
|
|
final List<Map<String, dynamic>> items;
|
|
final String search;
|
|
final int page;
|
|
final int limit;
|
|
final int total;
|
|
final int totalPages;
|
|
final bool isDeleting;
|
|
final bool isExporting;
|
|
final String? actionError;
|
|
|
|
MasterListState copyWith({
|
|
List<Map<String, dynamic>>? items,
|
|
String? search,
|
|
int? page,
|
|
int? limit,
|
|
int? total,
|
|
int? totalPages,
|
|
bool? isDeleting,
|
|
bool? isExporting,
|
|
String? actionError,
|
|
bool clearError = false,
|
|
}) {
|
|
return MasterListState(
|
|
items: items ?? this.items,
|
|
search: search ?? this.search,
|
|
page: page ?? this.page,
|
|
limit: limit ?? this.limit,
|
|
total: total ?? this.total,
|
|
totalPages: totalPages ?? this.totalPages,
|
|
isDeleting: isDeleting ?? this.isDeleting,
|
|
isExporting: isExporting ?? this.isExporting,
|
|
actionError: clearError ? null : actionError ?? this.actionError,
|
|
);
|
|
}
|
|
}
|
|
|
|
class MasterFormState {
|
|
const MasterFormState({
|
|
this.values = const {},
|
|
this.dropdownOptions = const {},
|
|
this.existingRecords = const [],
|
|
this.isSubmitting = false,
|
|
this.errorMessage,
|
|
});
|
|
|
|
final Map<String, dynamic> values;
|
|
final Map<String, List<Map<String, dynamic>>> dropdownOptions;
|
|
final List<Map<String, dynamic>> existingRecords;
|
|
final bool isSubmitting;
|
|
final String? errorMessage;
|
|
|
|
MasterFormState copyWith({
|
|
Map<String, dynamic>? values,
|
|
Map<String, List<Map<String, dynamic>>>? dropdownOptions,
|
|
List<Map<String, dynamic>>? existingRecords,
|
|
bool? isSubmitting,
|
|
String? errorMessage,
|
|
bool clearError = false,
|
|
}) {
|
|
return MasterFormState(
|
|
values: values ?? this.values,
|
|
dropdownOptions: dropdownOptions ?? this.dropdownOptions,
|
|
existingRecords: existingRecords ?? this.existingRecords,
|
|
isSubmitting: isSubmitting ?? this.isSubmitting,
|
|
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
|
|
);
|
|
}
|
|
}
|
|
|
|
final masterListProvider = AsyncNotifierProvider.family<
|
|
MasterListNotifier, MasterListState, String>(MasterListNotifier.new);
|
|
|
|
class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
|
final _columnSearch = ColumnSearchPaging(
|
|
defaultLimit: AppConstants.defaultPageSize,
|
|
);
|
|
|
|
MasterDefinition get _definition {
|
|
final def = masterDefinitionById(arg);
|
|
if (def == null) throw StateError('Unknown master: $arg');
|
|
return def;
|
|
}
|
|
|
|
@override
|
|
Future<MasterListState> build(String arg) async {
|
|
ref.keepAlive();
|
|
return _load();
|
|
}
|
|
|
|
Future<MasterListState> _load({
|
|
int? page,
|
|
int? limit,
|
|
String? search,
|
|
}) async {
|
|
final current = state.valueOrNull;
|
|
final nextPage = page ?? current?.page ?? 1;
|
|
final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize;
|
|
final nextSearch = TableSearch.normalize(search ?? current?.search);
|
|
final repository = ref.read(masterRepositoryProvider);
|
|
|
|
final result = await repository.list(
|
|
_definition,
|
|
page: nextPage,
|
|
limit: nextLimit,
|
|
search: nextSearch.isEmpty ? null : nextSearch,
|
|
);
|
|
|
|
if (result.failure != null) throw result.failure!;
|
|
var items = List<Map<String, dynamic>>.from(result.data!.items);
|
|
|
|
// API `search` often ignores related/enum fields (e.g. category_type).
|
|
// Supplement with dedicated filters and client-side multi-field matching.
|
|
if (nextSearch.isNotEmpty) {
|
|
if (_definition.id == 'item_categories') {
|
|
var typeMatches = 0;
|
|
for (final type in categoryTypeOptions) {
|
|
if (!TableSearch.matches(nextSearch, [type])) continue;
|
|
if (++typeMatches > 5) break;
|
|
final typed = await repository.list(
|
|
_definition,
|
|
page: nextPage,
|
|
limit: nextLimit,
|
|
extraQueryParameters: {'category_type': type},
|
|
);
|
|
if (typed.failure == null && typed.data != null) {
|
|
items = TableSearch.mergeById(
|
|
items,
|
|
typed.data!.items,
|
|
(row) => row['id']?.toString() ?? '',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep API page results + enriched matches; pagination stays server-driven.
|
|
}
|
|
|
|
final data = result.data!;
|
|
return MasterListState(
|
|
items: items,
|
|
search: nextSearch,
|
|
page: data.page,
|
|
// Keep the requested page size so the /page dropdown stays valid
|
|
// even if API meta omits or mismatches `limit`.
|
|
limit: nextLimit,
|
|
total: data.total,
|
|
totalPages: resolveTotalPages(total: data.total, limit: nextLimit),
|
|
);
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
final previous = state.valueOrNull;
|
|
if (previous == null) {
|
|
state = const AsyncLoading();
|
|
}
|
|
try {
|
|
state = AsyncData(await _load());
|
|
} catch (e, st) {
|
|
state = AsyncError(e, st);
|
|
}
|
|
}
|
|
|
|
Future<void> setSearch(String search) async {
|
|
await _reload(page: 1, search: search);
|
|
}
|
|
|
|
Future<void> ensureColumnSearchDataset() async {
|
|
final current = state.valueOrNull;
|
|
if (current == null) return;
|
|
final limit = _columnSearch.beginFullDataset(
|
|
currentLimit: current.limit,
|
|
total: current.total,
|
|
);
|
|
if (limit == null) return;
|
|
await _reload(page: 1, limit: limit, search: '');
|
|
}
|
|
|
|
void clearColumnSearchDataset() {
|
|
final current = state.valueOrNull;
|
|
if (current == null) return;
|
|
final limit = _columnSearch.endFullDataset();
|
|
if (limit == null) return;
|
|
_reload(page: 1, limit: limit, search: '');
|
|
}
|
|
|
|
/// Clears generic search and reloads the full list.
|
|
Future<void> clearSearch() async {
|
|
final current = state.valueOrNull;
|
|
if (current != null && current.search.isEmpty) return;
|
|
await _reload(page: 1, search: '');
|
|
}
|
|
|
|
Future<void> setPage(int page) async {
|
|
await _reload(page: page);
|
|
}
|
|
|
|
Future<void> setPageSize(int limit) async {
|
|
await _reload(page: 1, limit: limit);
|
|
}
|
|
|
|
Future<void> _reload({int? page, int? limit, String? search}) async {
|
|
final previous = state.valueOrNull;
|
|
if (previous == null) {
|
|
state = const AsyncLoading();
|
|
}
|
|
try {
|
|
state = AsyncData(
|
|
await _load(page: page, limit: limit, search: search),
|
|
);
|
|
} catch (e, st) {
|
|
state = AsyncError(e, st);
|
|
}
|
|
}
|
|
|
|
Future<bool> deleteRecord(String id) async {
|
|
final current = state.valueOrNull ?? const MasterListState();
|
|
state = AsyncData(current.copyWith(isDeleting: true));
|
|
|
|
final result = await ref.read(masterRepositoryProvider).delete(_definition, id);
|
|
if (result.failure != null) {
|
|
state = AsyncData(current.copyWith(isDeleting: false));
|
|
return false;
|
|
}
|
|
|
|
await refresh();
|
|
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
|
|
return true;
|
|
}
|
|
|
|
Future<ExportFileResult?> exportRecords() async {
|
|
final current = state.valueOrNull;
|
|
if (current == null) return null;
|
|
|
|
state = AsyncData(current.copyWith(isExporting: true, clearError: true));
|
|
|
|
final result = await ref.read(masterRepositoryProvider).export(
|
|
_definition,
|
|
search: current.search.isEmpty ? null : current.search,
|
|
);
|
|
final latest = state.valueOrNull ?? current;
|
|
|
|
if (result.failure != null) {
|
|
state = AsyncData(
|
|
latest.copyWith(
|
|
isExporting: false,
|
|
actionError: result.failure!.message,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
|
|
state = AsyncData(latest.copyWith(isExporting: false));
|
|
return result.data;
|
|
}
|
|
}
|
|
|
|
typedef MasterFormArgs = ({
|
|
String masterId,
|
|
String? recordId,
|
|
Map<String, dynamic>? initialValues,
|
|
String formSessionId,
|
|
});
|
|
|
|
final masterFormProvider = AsyncNotifierProvider.family<
|
|
MasterFormNotifier, MasterFormState, MasterFormArgs>(MasterFormNotifier.new);
|
|
|
|
class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterFormArgs> {
|
|
MasterDefinition get _definition {
|
|
final def = masterDefinitionById(arg.masterId);
|
|
if (def == null) throw StateError('Unknown master: ${arg.masterId}');
|
|
return def;
|
|
}
|
|
|
|
@override
|
|
Future<MasterFormState> build(MasterFormArgs arg) async {
|
|
final existingRecords = await _loadExistingRecords();
|
|
Map<String, dynamic> values = {};
|
|
|
|
if (arg.recordId != null) {
|
|
final result = await ref
|
|
.read(masterRepositoryProvider)
|
|
.getById(_definition, arg.recordId!);
|
|
if (result.failure != null) throw result.failure!;
|
|
values = Map<String, dynamic>.from(result.data ?? const {});
|
|
final tags = values['tags'];
|
|
if (tags is List) {
|
|
values['tags'] = tags
|
|
.map((e) => e.toString().trim())
|
|
.where((e) => e.isNotEmpty)
|
|
.join(', ');
|
|
}
|
|
} else {
|
|
for (final field in _definition.formFields) {
|
|
if (field.type == MasterFieldType.boolean) {
|
|
values[field.key] = field.key == 'is_active' ? true : false;
|
|
}
|
|
}
|
|
final initials = arg.initialValues;
|
|
if (initials != null && initials.isNotEmpty) {
|
|
values.addAll(initials);
|
|
}
|
|
}
|
|
|
|
_sanitizeLocationValues(values);
|
|
|
|
// Load after values so Items category options use is_asset_item.
|
|
final dropdownOptions = await _loadDropdownOptions(values: values);
|
|
|
|
if (_definition.id == 'items') {
|
|
final syncState = MasterFormState(
|
|
values: values,
|
|
dropdownOptions: dropdownOptions,
|
|
existingRecords: existingRecords,
|
|
);
|
|
_applyGstFromHsn(values, syncState);
|
|
}
|
|
|
|
return MasterFormState(
|
|
values: values,
|
|
dropdownOptions: dropdownOptions,
|
|
existingRecords: existingRecords,
|
|
);
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> _loadExistingRecords() async {
|
|
final allItems = <Map<String, dynamic>>[];
|
|
var page = 1;
|
|
|
|
while (true) {
|
|
final result = await ref.read(masterRepositoryProvider).list(
|
|
_definition,
|
|
page: page,
|
|
limit: AppConstants.defaultPageSize,
|
|
);
|
|
if (result.failure != null) break;
|
|
|
|
final data = result.data!;
|
|
allItems.addAll(data.items);
|
|
if (page >= data.totalPages) break;
|
|
page++;
|
|
}
|
|
|
|
return allItems;
|
|
}
|
|
|
|
Future<Map<String, List<Map<String, dynamic>>>>
|
|
_loadDropdownOptions({Map<String, dynamic>? values}) async {
|
|
final formValues = values ?? state.valueOrNull?.values ?? const {};
|
|
final options = <String, List<Map<String, dynamic>>>{};
|
|
final fields = _definition.formFields
|
|
.where((field) => field.optionsMasterKey != null);
|
|
|
|
for (final field in fields) {
|
|
final lookupKey = masterFieldDropdownLookupKey(
|
|
masterId: _definition.id,
|
|
field: field,
|
|
values: formValues,
|
|
);
|
|
if (options.containsKey(lookupKey)) continue;
|
|
|
|
final key = field.optionsMasterKey!;
|
|
if (key == 'asset_depreciation_methods') {
|
|
final result = await ref.read(assetRepositoryProvider).getDepreciationMethods();
|
|
if (result.failure != null) continue;
|
|
options[lookupKey] = (result.data ?? const [])
|
|
.where((option) => option.value.trim().isNotEmpty)
|
|
.map(
|
|
(option) => <String, dynamic>{
|
|
'id': option.value,
|
|
'name': option.label,
|
|
},
|
|
)
|
|
.toList();
|
|
continue;
|
|
}
|
|
|
|
if (key == 'location_states') {
|
|
try {
|
|
final states = await ref
|
|
.read(masterRemoteDataSourceProvider)
|
|
.listLocationStates();
|
|
options[lookupKey] = states
|
|
.map(
|
|
(option) => <String, dynamic>{
|
|
'id': option.id,
|
|
'name': option.name,
|
|
},
|
|
)
|
|
.toList();
|
|
} catch (_) {}
|
|
continue;
|
|
}
|
|
|
|
final def = masterDefinitionById(key);
|
|
if (def == null) continue;
|
|
final queryParameters = masterFieldOptionsQuery(
|
|
masterId: _definition.id,
|
|
field: field,
|
|
values: formValues,
|
|
);
|
|
final result = await ref.read(masterRepositoryProvider).listOptions(
|
|
def,
|
|
queryParameters: queryParameters,
|
|
);
|
|
if (result.failure != null) continue;
|
|
options[lookupKey] = result.data ?? const [];
|
|
}
|
|
return options;
|
|
}
|
|
|
|
void _applyGstFromHsn(Map<String, dynamic> values, MasterFormState current) {
|
|
if (_definition.id != 'items') return;
|
|
final hsnId = values['hsn_code_id']?.toString();
|
|
if (hsnId == null || hsnId.isEmpty) {
|
|
values['gst_rate_id'] = null;
|
|
values['gst_rate'] = null;
|
|
values['gst_rate_label'] = null;
|
|
return;
|
|
}
|
|
|
|
final hsnOptions = current.dropdownOptions['hsn_codes'] ?? const [];
|
|
Map<String, dynamic>? hsnRow;
|
|
for (final row in hsnOptions) {
|
|
if (row['id']?.toString() == hsnId) {
|
|
hsnRow = row;
|
|
break;
|
|
}
|
|
}
|
|
if (hsnRow == null) return;
|
|
|
|
final nestedGst = hsnRow['gst_rate'];
|
|
final gstId = hsnRow['gst_rate_id'] ??
|
|
(nestedGst is Map ? nestedGst['id'] : null);
|
|
if (gstId != null && gstId.toString().isNotEmpty) {
|
|
values['gst_rate_id'] = gstId.toString();
|
|
} else {
|
|
values['gst_rate_id'] = null;
|
|
}
|
|
|
|
if (nestedGst is Map) {
|
|
values['gst_rate'] = Map<String, dynamic>.from(nestedGst);
|
|
final desc = nestedGst['description'];
|
|
values['gst_rate_label'] =
|
|
desc != null && desc.toString().trim().isNotEmpty
|
|
? desc.toString().trim()
|
|
: gstRateDisplayFromValues(values);
|
|
} else {
|
|
values['gst_rate'] = null;
|
|
values['gst_rate_label'] = null;
|
|
}
|
|
}
|
|
|
|
void _clearHiddenFieldValues(Map<String, dynamic> values) {
|
|
for (final field in _definition.formFields) {
|
|
if (field.isVisibleInForm(values)) continue;
|
|
values[field.key] = null;
|
|
}
|
|
}
|
|
|
|
/// Warehouses are no longer nested under plants — drop obsolete keys.
|
|
void _sanitizeLocationValues(Map<String, dynamic> values) {
|
|
if (_definition.id != 'locations') return;
|
|
values.remove('parent_id');
|
|
values.remove('plant_id');
|
|
values.remove('plant');
|
|
}
|
|
|
|
void updateValue(String key, dynamic value) {
|
|
final current = state.valueOrNull;
|
|
if (current == null) return;
|
|
final values = Map<String, dynamic>.from(current.values);
|
|
values[key] = value;
|
|
|
|
if (key == 'hsn_code_id') {
|
|
_applyGstFromHsn(values, current);
|
|
}
|
|
|
|
if (key == 'category_type') {
|
|
_clearHiddenFieldValues(values);
|
|
}
|
|
|
|
if (key == 'type' && _definition.id == 'locations') {
|
|
_clearHiddenFieldValues(values);
|
|
_sanitizeLocationValues(values);
|
|
}
|
|
|
|
// Asset Item toggles Items category list between STOCK / ASSET
|
|
// and hides stock-only fields (min order qty / reorder level).
|
|
if (key == 'is_asset_item' && _definition.id == 'items') {
|
|
values['item_category_id'] = null;
|
|
values['item_subcategory_id'] = null;
|
|
_clearHiddenFieldValues(values);
|
|
state = AsyncData(current.copyWith(values: values));
|
|
_reloadItemCategoryOptions(values);
|
|
return;
|
|
}
|
|
|
|
// Clear dependent dropdowns when their parent filter value changes
|
|
// (e.g. item_category_id → item_subcategory_id).
|
|
for (final field in _definition.formFields) {
|
|
if (field.filterByFieldKey != key) continue;
|
|
final dependentValue = values[field.key];
|
|
if (dependentValue == null || dependentValue == '') continue;
|
|
|
|
final optionKey = field.filterByOptionKey ?? field.filterByFieldKey!;
|
|
final lookupKey = masterFieldDropdownLookupKey(
|
|
masterId: _definition.id,
|
|
field: field,
|
|
values: values,
|
|
);
|
|
final options = current.dropdownOptions[lookupKey] ?? const [];
|
|
final stillValid = options.any(
|
|
(item) =>
|
|
item['id']?.toString() == dependentValue.toString() &&
|
|
item[optionKey]?.toString() == value?.toString(),
|
|
);
|
|
if (!stillValid) {
|
|
values[field.key] = null;
|
|
}
|
|
}
|
|
|
|
state = AsyncData(current.copyWith(values: values));
|
|
}
|
|
|
|
Future<void> _reloadItemCategoryOptions(Map<String, dynamic> values) async {
|
|
final current = state.valueOrNull;
|
|
if (current == null) return;
|
|
final options = await _loadDropdownOptions(values: values);
|
|
final latest = state.valueOrNull;
|
|
if (latest == null) return;
|
|
state = AsyncData(latest.copyWith(dropdownOptions: options));
|
|
}
|
|
|
|
Future<void> reloadDropdownOptions() async {
|
|
final current = state.valueOrNull;
|
|
if (current == null) return;
|
|
final options = await _loadDropdownOptions(values: current.values);
|
|
// Re-read latest state so a Quick Add selection applied while we were
|
|
// loading is not overwritten by the snapshot from the start of this call.
|
|
final latest = state.valueOrNull;
|
|
if (latest == null) return;
|
|
state = AsyncData(latest.copyWith(dropdownOptions: options));
|
|
}
|
|
|
|
Map<String, dynamic> _buildPayload(MasterFormState current) {
|
|
final payload = <String, dynamic>{};
|
|
for (final field in _definition.formFields) {
|
|
if (!field.isVisibleInForm(current.values)) continue;
|
|
final value = current.values[field.key];
|
|
if (value == null || value == '') continue;
|
|
|
|
if (field.key == 'tags') {
|
|
final tags = _parseTags(value);
|
|
if (tags.isNotEmpty) payload['tags'] = tags;
|
|
continue;
|
|
}
|
|
|
|
payload[field.key] = switch (field.type) {
|
|
MasterFieldType.number => num.tryParse(value.toString()) ?? value,
|
|
MasterFieldType.dropdown => field.staticOptions != null ||
|
|
field.optionsMasterKey == 'asset_depreciation_methods' ||
|
|
field.optionsMasterKey == 'location_states'
|
|
? value.toString()
|
|
: int.tryParse(value.toString()) ?? value,
|
|
MasterFieldType.boolean => value == true,
|
|
MasterFieldType.text => value.toString().trim(),
|
|
};
|
|
}
|
|
|
|
// Never send plant nesting fields for locations (API breaking change).
|
|
if (_definition.id == 'locations') {
|
|
payload.remove('parent_id');
|
|
payload.remove('plant_id');
|
|
payload.remove('plant');
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
List<String> _parseTags(dynamic value) {
|
|
if (value is List) {
|
|
return value
|
|
.map((e) => e.toString().trim())
|
|
.where((e) => e.isNotEmpty)
|
|
.take(50)
|
|
.map((e) => e.length > 50 ? e.substring(0, 50) : e)
|
|
.toSet()
|
|
.toList();
|
|
}
|
|
final parts = value
|
|
.toString()
|
|
.split(RegExp(r'[,;\n]+'))
|
|
.map((e) => e.trim())
|
|
.where((e) => e.isNotEmpty)
|
|
.take(50)
|
|
.map((e) => e.length > 50 ? e.substring(0, 50) : e);
|
|
return parts.toSet().toList();
|
|
}
|
|
|
|
/// Returns the created/updated record id on success, otherwise null.
|
|
Future<String?> submit() async {
|
|
final current = state.valueOrNull ?? const MasterFormState();
|
|
state = AsyncData(current.copyWith(isSubmitting: true, clearError: true));
|
|
|
|
final payload = _buildPayload(current);
|
|
final result = arg.recordId == null
|
|
? await ref.read(masterRepositoryProvider).create(_definition, payload)
|
|
: await ref
|
|
.read(masterRepositoryProvider)
|
|
.update(_definition, arg.recordId!, payload);
|
|
|
|
if (result.failure != null) {
|
|
state = AsyncData(
|
|
current.copyWith(
|
|
isSubmitting: false,
|
|
errorMessage: result.failure!.message,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
|
|
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
|
|
// Keep Asset/PO/GRN/User/Vendor dropdown caches in sync with Master Data.
|
|
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
|
|
ref.invalidate(masterListProvider(_definition.id));
|
|
final data = result.data;
|
|
final createdId = _readCreatedId(data);
|
|
if (createdId != null && createdId.isNotEmpty) return createdId;
|
|
return arg.recordId ?? 'created';
|
|
}
|
|
|
|
String? _readCreatedId(Map<String, dynamic>? data) {
|
|
if (data == null) return null;
|
|
for (final key in const ['id', 'ID', 'gst_rate_id']) {
|
|
final value = data[key];
|
|
if (value != null && value.toString().trim().isNotEmpty) {
|
|
return value.toString().trim();
|
|
}
|
|
}
|
|
final nested = data['data'];
|
|
if (nested is Map) {
|
|
final value = nested['id'];
|
|
if (value != null && value.toString().trim().isNotEmpty) {
|
|
return value.toString().trim();
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|