1477 lines
49 KiB
Dart
1477 lines
49 KiB
Dart
import 'dart:convert';
|
||
import 'dart:math' as math;
|
||
|
||
import 'package:dropdown_search/dropdown_search.dart';
|
||
import 'package:file_picker/file_picker.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:http/http.dart' as http;
|
||
|
||
import '../../../../core/config/env.dart';
|
||
import '../../../../core/routing/routes.dart';
|
||
import '../../../../core/services/api_service.dart';
|
||
import '../../../../data/services/auth_service.dart';
|
||
import '../../../../data/utils/toastNotification.dart';
|
||
import '../../../../data/utils/validators.dart';
|
||
import '../../../layouts/main_layout.dart';
|
||
import '../../../layouts/responsive_layout.dart';
|
||
import '../../../providers/manager_provider.dart';
|
||
import '../../../providers/user_provider.dart';
|
||
import '../../../providers/userRoleProvider.dart';
|
||
import '../../../themes/indicators/input_field_decoration.dart';
|
||
import '../../../themes/indicators/search_field_theme.dart';
|
||
import '../../../themes/indicators/text_field_theme.dart';
|
||
import '../../../themes/indicators/customizd_file_upload.dart';
|
||
|
||
/// Full-page partner (agent) create / edit with per–vehicle-type retention rates.
|
||
class AgentDetailsScreen extends ConsumerStatefulWidget {
|
||
const AgentDetailsScreen({super.key, required this.agentId});
|
||
|
||
/// Use `'new'` for create, otherwise the numeric agent id as string.
|
||
final String agentId;
|
||
|
||
bool get isCreate {
|
||
final s = agentId.trim().toLowerCase();
|
||
return s == 'new' || s == 'create';
|
||
}
|
||
|
||
@override
|
||
ConsumerState<AgentDetailsScreen> createState() => _AgentDetailsScreenState();
|
||
}
|
||
|
||
class _AgentDetailsScreenState extends ConsumerState<AgentDetailsScreen> {
|
||
/// Digits only, max 10 (India-style mobile).
|
||
static final List<TextInputFormatter> _phoneInputFormatters = [
|
||
FilteringTextInputFormatter.digitsOnly,
|
||
LengthLimitingTextInputFormatter(10),
|
||
];
|
||
|
||
/// Percentage 0–100 with up to 2 decimal places; cannot exceed 100.
|
||
static final List<TextInputFormatter> _retentionPercentFormatters = [
|
||
FilteringTextInputFormatter.allow(RegExp(r'^\d{0,3}(\.\d{0,2})?$')),
|
||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||
if (newValue.text.isEmpty) return newValue;
|
||
final v = double.tryParse(newValue.text);
|
||
if (v == null) return oldValue;
|
||
if (v > 100 || v < 0) return oldValue;
|
||
return newValue;
|
||
}),
|
||
];
|
||
|
||
final _formKey = GlobalKey<FormState>();
|
||
final ApiService _api = ApiService();
|
||
|
||
final _nameCtrl = TextEditingController();
|
||
final _emailCtrl = TextEditingController();
|
||
final _mobileCtrl = TextEditingController();
|
||
final _codeCtrl = TextEditingController();
|
||
final _addressCtrl = TextEditingController();
|
||
final _retentionSearchCtrl = TextEditingController();
|
||
|
||
final GlobalKey<DropdownSearchState<Map<String, dynamic>>> _seKey =
|
||
GlobalKey<DropdownSearchState<Map<String, dynamic>>>();
|
||
|
||
List<Map<String, dynamic>> _vehicleTypes = [];
|
||
final Map<String, TextEditingController> _rateControllers = {};
|
||
|
||
List<Map<String, dynamic>> _salesExecutives = [];
|
||
dynamic _selectedSalesExecutiveId;
|
||
|
||
PlatformFile? _certificateFile;
|
||
String? _certificateLabel;
|
||
String? _certificateUrlFromApi;
|
||
String? _token;
|
||
String? _loadedAgentPk;
|
||
|
||
bool _loading = true;
|
||
bool _saving = false;
|
||
bool _savingAllRetention = false;
|
||
|
||
/// Retention rows can be saved only after the partner exists (`agent_id`).
|
||
bool get _retentionTableEnabled =>
|
||
_loadedAgentPk != null && _loadedAgentPk!.trim().isNotEmpty;
|
||
|
||
bool get _retentionActionsBusy => _saving || _savingAllRetention;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_retentionSearchCtrl.addListener(() => setState(() {}));
|
||
_bootstrap();
|
||
}
|
||
|
||
/// After create, [context.go] to `/agentDetails/:id` can update [widget.agentId] while
|
||
/// reusing this [State] — [initState] does not run again, so we must load the new id here.
|
||
@override
|
||
void didUpdateWidget(covariant AgentDetailsScreen oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.agentId != widget.agentId && !widget.isCreate) {
|
||
_reloadAgentForRouteIdChange();
|
||
}
|
||
}
|
||
|
||
Future<void> _reloadAgentForRouteIdChange() async {
|
||
await _loadAgent();
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
Future<void> _bootstrap() async {
|
||
_token = await AuthService.getToken();
|
||
await _loadSalesExecutives();
|
||
await _loadVehicleTypes();
|
||
if (!widget.isCreate) {
|
||
await _loadAgent();
|
||
} else {
|
||
_initRateControllersFromTypes();
|
||
}
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
|
||
Future<void> _loadSalesExecutives() async {
|
||
final managerId = ref.read(managerIdProvider);
|
||
final role = ref.read(userRoleProvider);
|
||
if (managerId == null || role == null) return;
|
||
try {
|
||
final res = await _api.fetchSalesExecutiveList(managerId, role);
|
||
if (res['status'] == 'success' && res['data'] is List) {
|
||
final list = List<Map<String, dynamic>>.from(res['data']);
|
||
setState(() {
|
||
_salesExecutives =
|
||
list.where((e) => int.tryParse(e['is_active'].toString()) == 1).toList();
|
||
});
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
Future<void> _loadVehicleTypes() async {
|
||
try {
|
||
final res = await _api.fetchPartnerVehicleTypes();
|
||
if (res['status'] == 'success' && res['data'] is List) {
|
||
setState(() {
|
||
_vehicleTypes = List<Map<String, dynamic>>.from(res['data']);
|
||
});
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
String _retentionKey(int vehicleTypeId, int segmentId) => '${vehicleTypeId}_$segmentId';
|
||
|
||
int _readInt(dynamic value) => int.tryParse(value?.toString() ?? '') ?? 0;
|
||
|
||
String _displayVehicleSegment(Map<String, dynamic> vt) {
|
||
final name = (vt['vehicle_type'] ?? '-').toString();
|
||
final segment = (vt['segment'] ?? '').toString().trim();
|
||
if (segment.isEmpty) return name;
|
||
return '$name - $segment';
|
||
}
|
||
|
||
void _initRateControllersFromTypes({Map<String, String>? initial}) {
|
||
for (final c in _rateControllers.values) {
|
||
c.dispose();
|
||
}
|
||
_rateControllers.clear();
|
||
for (final vt in _vehicleTypes) {
|
||
final vehicleTypeId = _readInt(vt['vehicle_type_id'] ?? vt['id']);
|
||
final segmentId = _readInt(vt['segment_id']);
|
||
if (vehicleTypeId == 0 || segmentId == 0) continue;
|
||
final key = _retentionKey(vehicleTypeId, segmentId);
|
||
final text = initial?[key] ?? '';
|
||
_rateControllers[key] = TextEditingController(text: text);
|
||
}
|
||
}
|
||
|
||
/// Builds (vehicle_type_id + segment_id) → retention % text from [findAgent].
|
||
Map<String, String> _retentionMapFromAgentData(Map<String, dynamic> data) {
|
||
final Map<String, String> initial = {};
|
||
final raw = data['retention_by_vehicle'] ?? data['retentionByVehicle'];
|
||
if (raw is! List) return initial;
|
||
for (final row in raw) {
|
||
if (row is! Map) continue;
|
||
final m = Map<String, dynamic>.from(row);
|
||
final vid = _readInt(m['vehicle_type_id'] ?? m['vehicleTypeId']);
|
||
final sid = _readInt(m['segment_id'] ?? m['segmentId']);
|
||
if (vid == 0 || sid == 0) continue;
|
||
final rate = m['retention_rate'] ?? m['retentionRate'];
|
||
if (rate == null) continue;
|
||
initial[_retentionKey(vid, sid)] = rate.toString();
|
||
}
|
||
return initial;
|
||
}
|
||
|
||
Future<void> _loadAgent({bool silent = false}) async {
|
||
try {
|
||
final res = await _api.findSingleAgentData(widget.agentId);
|
||
if (res['status'] != 'success' || res['data'] == null) {
|
||
if (!silent && mounted) {
|
||
ToastHelper.showErrorToast(context, 'Partner not found');
|
||
context.go(AppRoutes.agentLst);
|
||
}
|
||
return;
|
||
}
|
||
final data = Map<String, dynamic>.from(res['data'] as Map);
|
||
final pk = data['id'] ?? data['agent_id'];
|
||
_loadedAgentPk = pk?.toString().trim();
|
||
if (_loadedAgentPk != null && _loadedAgentPk!.isEmpty) {
|
||
_loadedAgentPk = null;
|
||
}
|
||
|
||
_nameCtrl.text = (data['name'] ?? '').toString();
|
||
_emailCtrl.text = (data['email'] ?? '').toString();
|
||
_mobileCtrl.text = (data['mobile'] ?? '').toString();
|
||
_codeCtrl.text = (data['agent_code'] ?? '').toString();
|
||
_addressCtrl.text = (data['address'] ?? '').toString();
|
||
|
||
if (data['sales_executive_id'] != null) {
|
||
_selectedSalesExecutiveId = int.tryParse(data['sales_executive_id'].toString());
|
||
}
|
||
|
||
final cert = data['certificate_file_name']?.toString();
|
||
if (cert != null && cert.isNotEmpty) {
|
||
_certificateLabel = cert.split('/').last;
|
||
_certificateUrlFromApi = cert;
|
||
_certificateFile = null;
|
||
}
|
||
|
||
final initial = _retentionMapFromAgentData(data);
|
||
_initRateControllersFromTypes(initial: initial);
|
||
if (silent && mounted) setState(() {});
|
||
} catch (e) {
|
||
if (!silent && mounted) {
|
||
ToastHelper.showErrorToast(context, 'Failed to load partner');
|
||
context.go(AppRoutes.agentLst);
|
||
}
|
||
}
|
||
}
|
||
|
||
List<Map<String, dynamic>> get _filteredVehicleTypes {
|
||
final q = _retentionSearchCtrl.text.trim().toLowerCase();
|
||
if (q.isEmpty) return _vehicleTypes;
|
||
final terms = q.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||
return _vehicleTypes.where((vt) {
|
||
final vehicle = (vt['vehicle_type'] ?? '').toString().toLowerCase();
|
||
final segment = (vt['segment'] ?? '').toString().toLowerCase();
|
||
final searchable = '$vehicle $segment';
|
||
return terms.every(searchable.contains);
|
||
}).toList();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameCtrl.dispose();
|
||
_emailCtrl.dispose();
|
||
_mobileCtrl.dispose();
|
||
_codeCtrl.dispose();
|
||
_addressCtrl.dispose();
|
||
_retentionSearchCtrl.dispose();
|
||
for (final c in _rateControllers.values) {
|
||
c.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
/// Collect valid rates from **all** vehicle types (not search-filtered) for bulk API.
|
||
List<Map<String, dynamic>> _collectBulkRetentionPayload() {
|
||
final out = <Map<String, dynamic>>[];
|
||
for (final vt in _vehicleTypes) {
|
||
final vehicleTypeId = _readInt(vt['vehicle_type_id'] ?? vt['id']);
|
||
final segmentId = _readInt(vt['segment_id']);
|
||
if (vehicleTypeId == 0 || segmentId == 0) continue;
|
||
final key = _retentionKey(vehicleTypeId, segmentId);
|
||
final ctrl = _rateControllers[key];
|
||
if (ctrl == null) continue;
|
||
final t = ctrl.text.trim();
|
||
if (t.isEmpty) continue;
|
||
final v = double.tryParse(t);
|
||
if (v == null || v < 0 || v > 100) continue;
|
||
out.add({
|
||
'vehicle_type_id': vehicleTypeId,
|
||
'segment_id': segmentId,
|
||
'retention_rate': v,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
Future<void> _saveAllRetentionRates() async {
|
||
if (!_retentionTableEnabled) {
|
||
ToastHelper.showWarningToast(
|
||
context,
|
||
'Save partner details first to get an agent id',
|
||
);
|
||
return;
|
||
}
|
||
if (_retentionActionsBusy) return;
|
||
|
||
final rates = _collectBulkRetentionPayload();
|
||
if (rates.isEmpty) {
|
||
ToastHelper.showWarningToast(
|
||
context,
|
||
'Enter at least one retention rate (0–100%)',
|
||
);
|
||
return;
|
||
}
|
||
|
||
final userId = ref.read(userIdProvider)?.toString() ?? '';
|
||
if (userId.isEmpty) {
|
||
ToastHelper.showErrorToast(context, 'Please log in again');
|
||
return;
|
||
}
|
||
|
||
if (_token == null) {
|
||
_token = await AuthService.getToken();
|
||
}
|
||
if (_token == null) {
|
||
ToastHelper.showErrorToast(context, 'Please log in again');
|
||
return;
|
||
}
|
||
|
||
setState(() => _savingAllRetention = true);
|
||
try {
|
||
final res = await _api.saveAgentRetentionRatesBulk(
|
||
agentId: _loadedAgentPk!,
|
||
retentionRates: rates,
|
||
updatedBy: userId,
|
||
);
|
||
if (res['status'] == 'success') {
|
||
if (mounted) {
|
||
ToastHelper.showSuccessToast(context, 'All retention rates saved');
|
||
context.go(AppRoutes.agentLst);
|
||
}
|
||
return;
|
||
} else {
|
||
ToastHelper.showErrorToast(
|
||
context,
|
||
res['data']?.toString() ?? 'Save failed',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
ToastHelper.showErrorToast(context, 'Save failed');
|
||
} finally {
|
||
if (mounted) setState(() => _savingAllRetention = false);
|
||
}
|
||
}
|
||
|
||
/// Backend may return the new row id under [data.id], [data.agent_id], or top-level keys.
|
||
String? _newAgentIdFromCreateBody(dynamic body) {
|
||
if (body is! Map) return null;
|
||
final m = Map<String, dynamic>.from(body);
|
||
final data = m['data'];
|
||
if (data is Map) {
|
||
final dm = Map<String, dynamic>.from(data);
|
||
for (final key in ['agent_id', 'id', 'agentId']) {
|
||
final v = dm[key];
|
||
final s = v?.toString().trim();
|
||
if (s != null && s.isNotEmpty && s != 'null') return s;
|
||
}
|
||
} else if (data != null && data is! List) {
|
||
final s = data.toString().trim();
|
||
if (s.isNotEmpty && s != 'null') return s;
|
||
}
|
||
for (final key in ['agent_id', 'id']) {
|
||
final v = m[key];
|
||
final s = v?.toString().trim();
|
||
if (s != null && s.isNotEmpty && s != 'null') return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<void> _saveRowRetention(Map<String, dynamic> vt) async {
|
||
if (!_retentionTableEnabled) {
|
||
ToastHelper.showWarningToast(
|
||
context,
|
||
'Save partner details first to get an agent id',
|
||
);
|
||
return;
|
||
}
|
||
if (_savingAllRetention) return;
|
||
final vehicleTypeId = _readInt(vt['vehicle_type_id'] ?? vt['id']);
|
||
final segmentId = _readInt(vt['segment_id']);
|
||
if (vehicleTypeId == 0 || segmentId == 0) {
|
||
ToastHelper.showErrorToast(context, 'Vehicle type or segment missing');
|
||
return;
|
||
}
|
||
final key = _retentionKey(vehicleTypeId, segmentId);
|
||
final ctrl = _rateControllers[key];
|
||
if (ctrl == null) return;
|
||
final t = ctrl.text.trim();
|
||
if (t.isEmpty) {
|
||
ToastHelper.showWarningToast(context, 'Enter a retention rate');
|
||
return;
|
||
}
|
||
final v = double.tryParse(t);
|
||
if (v == null || v < 0 || v > 100) {
|
||
ToastHelper.showWarningToast(context, 'Rate must be between 0 and 100');
|
||
return;
|
||
}
|
||
final userId = ref.read(userIdProvider)?.toString() ?? '';
|
||
if (userId.isEmpty) {
|
||
ToastHelper.showErrorToast(context, 'Please log in again');
|
||
return;
|
||
}
|
||
setState(() => _saving = true);
|
||
try {
|
||
final res = await _api.updateAgentVehicleRetention(
|
||
agentId: _loadedAgentPk!,
|
||
vehicleTypeId: '$vehicleTypeId',
|
||
segmentId: '$segmentId',
|
||
retentionRate: t,
|
||
updatedBy: userId,
|
||
);
|
||
if (res['status'] == 'success') {
|
||
if (mounted) {
|
||
ToastHelper.showSuccessToast(context, 'Retention updated');
|
||
await _loadAgent(silent: true);
|
||
}
|
||
} else {
|
||
ToastHelper.showErrorToast(
|
||
context,
|
||
res['data']?.toString() ?? 'Update failed',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
ToastHelper.showErrorToast(context, 'Update failed');
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _savePartnerDetails() async {
|
||
final ok = _formKey.currentState?.validate() ?? false;
|
||
if (!ok) {
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
FocusManager.instance.primaryFocus?.unfocus();
|
||
});
|
||
return;
|
||
}
|
||
|
||
final email = _emailCtrl.text.trim();
|
||
final phone = _mobileCtrl.text.trim();
|
||
if (_selectedSalesExecutiveId == null) {
|
||
ToastHelper.showWarningToast(context, 'Select a sales executive');
|
||
return;
|
||
}
|
||
|
||
if (_token == null) {
|
||
_token = await AuthService.getToken();
|
||
}
|
||
if (_token == null) {
|
||
ToastHelper.showErrorToast(context, 'Please log in again');
|
||
return;
|
||
}
|
||
|
||
final managerId = ref.read(managerIdProvider)?.toString() ?? '';
|
||
final userId = ref.read(userIdProvider)?.toString() ?? '';
|
||
final isUpdate = !widget.isCreate && _loadedAgentPk != null;
|
||
|
||
final uri = Uri.parse(
|
||
isUpdate ? '${Env.apiUrl}agent/updateAgent' : '${Env.apiUrl}agent/createAgent',
|
||
);
|
||
final request = http.MultipartRequest('POST', uri);
|
||
request.headers['Authorization'] = 'Bearer $_token';
|
||
request.headers['app-signature'] = Env.App_Signature;
|
||
|
||
request.fields['name'] = _nameCtrl.text.trim();
|
||
request.fields['email'] = email;
|
||
request.fields['mobile'] = phone;
|
||
request.fields['address'] = _addressCtrl.text.trim();
|
||
request.fields['agent_code'] = _codeCtrl.text.trim();
|
||
request.fields['sales_executive_id'] = _selectedSalesExecutiveId.toString();
|
||
request.fields['manager_id'] = managerId;
|
||
|
||
if (isUpdate) {
|
||
request.fields['id'] = _loadedAgentPk!;
|
||
request.fields['updated_by'] = userId;
|
||
} else {
|
||
request.fields['created_by'] = userId;
|
||
}
|
||
|
||
if (_certificateFile != null) {
|
||
try {
|
||
if (_certificateFile!.bytes != null) {
|
||
request.files.add(
|
||
http.MultipartFile.fromBytes(
|
||
'certificate_file_name',
|
||
_certificateFile!.bytes!,
|
||
filename: _certificateFile!.name,
|
||
),
|
||
);
|
||
} else if (_certificateFile!.path != null) {
|
||
request.files.add(
|
||
await http.MultipartFile.fromPath(
|
||
'certificate_file_name',
|
||
_certificateFile!.path!,
|
||
filename: _certificateFile!.name,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
ToastHelper.showErrorToast(context, 'Could not attach certificate');
|
||
return;
|
||
}
|
||
}
|
||
|
||
setState(() => _saving = true);
|
||
try {
|
||
final streamed = await request.send();
|
||
final response = await http.Response.fromStream(streamed);
|
||
if (response.statusCode == 403 || response.statusCode == 401) {
|
||
await _api.clearLocalStorageAndRedirect();
|
||
return;
|
||
}
|
||
final body = jsonDecode(response.body);
|
||
if (response.statusCode == 200 &&
|
||
body is Map &&
|
||
body['status'] == 'success') {
|
||
ToastHelper.showSuccessToast(
|
||
context,
|
||
isUpdate ? 'Partner updated' : 'Partner created',
|
||
);
|
||
if (isUpdate && mounted) {
|
||
context.go(AppRoutes.agentLst);
|
||
return;
|
||
}
|
||
if (!isUpdate && mounted) {
|
||
final newId = _newAgentIdFromCreateBody(body);
|
||
if (newId != null && newId.isNotEmpty) {
|
||
setState(() => _loadedAgentPk = newId);
|
||
context.go(AppRoutes.agentDetailsFor(newId));
|
||
return;
|
||
}
|
||
ToastHelper.showWarningToast(
|
||
context,
|
||
'Partner saved but agent id missing — open from list to set retention',
|
||
);
|
||
context.go(AppRoutes.agentLst);
|
||
return;
|
||
}
|
||
} else {
|
||
final msg = body is Map ? body['data']?.toString() : response.body;
|
||
ToastHelper.showErrorToast(context, msg ?? 'Save failed');
|
||
}
|
||
} catch (e) {
|
||
ToastHelper.showErrorToast(context, 'Save failed');
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
Map<String, dynamic>? _selectedSalesExecutiveMap() {
|
||
if (_selectedSalesExecutiveId == null) return null;
|
||
for (final e in _salesExecutives) {
|
||
if (e['id'].toString() == _selectedSalesExecutiveId.toString()) return e;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_loading) {
|
||
return MainLayout(
|
||
title: 'Partner',
|
||
body: const Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
|
||
final pageTitle = widget.isCreate ? 'New Partner' : 'Edit Partner';
|
||
|
||
return MainLayout(
|
||
title: 'Partner',
|
||
body: SelectionArea(
|
||
child: Container(
|
||
color: Colors.white,
|
||
width: double.infinity,
|
||
height: double.infinity,
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final mobile = ResponsiveLayout.isMobile(context);
|
||
final hPad = mobile ? 12.0 : 20.0;
|
||
final vPad = mobile ? 12.0 : 16.0;
|
||
final viewW = constraints.hasBoundedWidth && constraints.maxWidth.isFinite
|
||
? constraints.maxWidth
|
||
: MediaQuery.sizeOf(context).width;
|
||
final contentW = (viewW - 2 * hPad).clamp(280.0, double.infinity);
|
||
|
||
final form = Form(
|
||
key: _formKey,
|
||
autovalidateMode: AutovalidateMode.disabled,
|
||
child: mobile
|
||
? Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_pageHeaderBar(context, pageTitle),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: EdgeInsets.only(bottom: vPad),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_partnerDetailsHeading(),
|
||
const SizedBox(height: 14),
|
||
_partnerDetailsCard(context, contentW),
|
||
const SizedBox(height: 24),
|
||
_retentionSection(context, expandTableHeight: false),
|
||
SizedBox(height: mobile ? 24 : 16),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_pageHeaderBar(context, pageTitle),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
flex: 5,
|
||
child: _partnerDetailsHeading(),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
flex: 6,
|
||
child: _retentionTitleSearchRow(),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(
|
||
flex: 5,
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.only(right: 10),
|
||
child: LayoutBuilder(
|
||
builder: (ctx, c) {
|
||
final w = c.maxWidth.isFinite && c.maxWidth > 0
|
||
? c.maxWidth
|
||
: contentW * 0.48;
|
||
return _partnerDetailsCard(context, w);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
flex: 6,
|
||
child: _retentionSection(
|
||
context,
|
||
expandTableHeight: true,
|
||
showSectionTitleRow: false,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
return Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: vPad),
|
||
child: form,
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _partnerDetailsHeading() {
|
||
return Text(
|
||
'Partner Details',
|
||
style: GoogleFonts.poppins(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF1E293B),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _partnerDetailsCard(BuildContext context, double contentW) {
|
||
final pad = ResponsiveLayout.isMobile(context) ? 14.0 : 20.0;
|
||
return Container(
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFE6F4F1),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
padding: EdgeInsets.all(pad),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_partnerRows(context, contentW),
|
||
const SizedBox(height: 20),
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: FilledButton(
|
||
onPressed: (_saving || _savingAllRetention) ? null : _savePartnerDetails,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: const Color(0xFF2E7D6E),
|
||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 12),
|
||
),
|
||
child: _saving
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: const Text('Save'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Same pattern as [PayOutDetails] — list-style back control + title.
|
||
Widget _pageHeaderBar(BuildContext context, String pageTitle) {
|
||
return SizedBox(
|
||
height: 40,
|
||
width: double.infinity,
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Tooltip(
|
||
message: 'Back',
|
||
child: IconButton(
|
||
icon: const Icon(
|
||
Icons.arrow_left_sharp,
|
||
size: 25,
|
||
color: Color(0xFF425B5B),
|
||
),
|
||
onPressed: () => context.go(AppRoutes.agentLst),
|
||
splashRadius: 18,
|
||
hoverColor: Colors.black12,
|
||
padding: const EdgeInsets.all(4),
|
||
constraints: const BoxConstraints(),
|
||
),
|
||
),
|
||
const SizedBox(width: 5),
|
||
Expanded(
|
||
child: Text(
|
||
pageTitle,
|
||
style: GoogleFonts.inter(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: const Color(0xFF0F172A),
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _partnerRows(BuildContext context, double viewportWidth) {
|
||
final mobile = ResponsiveLayout.isMobile(context);
|
||
final usable = viewportWidth.isFinite ? viewportWidth.clamp(280.0, 1200.0) : 800.0;
|
||
const rowGap = 12.0;
|
||
const verticalGap = 16.0;
|
||
/// Desktop: two equal columns per row (one horizontal gap).
|
||
final fieldWidth = mobile
|
||
? double.infinity
|
||
: ((usable - rowGap) / 2).clamp(140.0, 520.0);
|
||
|
||
if (mobile) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_labeledField('Full name *', _nameCtrl, _reqName,
|
||
width: fieldWidth, keyboardType: TextInputType.name),
|
||
const SizedBox(height: 12),
|
||
_labeledField(
|
||
'Email *',
|
||
_emailCtrl,
|
||
_emailVal,
|
||
width: fieldWidth,
|
||
keyboardType: TextInputType.emailAddress,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_labeledField(
|
||
'Phone number *',
|
||
_mobileCtrl,
|
||
_phoneVal,
|
||
width: fieldWidth,
|
||
keyboardType: TextInputType.phone,
|
||
inputFormatters: _phoneInputFormatters,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_labeledField(
|
||
'Partner Id *',
|
||
_codeCtrl,
|
||
_reqCode,
|
||
width: fieldWidth,
|
||
inputFormatters: [
|
||
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9]')),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
_salesDropdown(context, fieldWidth),
|
||
const SizedBox(height: 12),
|
||
_labeledField(
|
||
'Address *',
|
||
_addressCtrl,
|
||
_reqAddress,
|
||
width: fieldWidth,
|
||
keyboardType: TextInputType.streetAddress,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_certificateColumn(context),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget pair(Widget a, Widget b) {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(child: a),
|
||
SizedBox(width: rowGap),
|
||
Expanded(child: b),
|
||
],
|
||
);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
pair(
|
||
_labeledField('Full name *', _nameCtrl, _reqName,
|
||
width: fieldWidth, keyboardType: TextInputType.name),
|
||
_labeledField(
|
||
'Email *',
|
||
_emailCtrl,
|
||
_emailVal,
|
||
width: fieldWidth,
|
||
keyboardType: TextInputType.emailAddress,
|
||
),
|
||
),
|
||
const SizedBox(height: verticalGap),
|
||
pair(
|
||
_labeledField(
|
||
'Phone number *',
|
||
_mobileCtrl,
|
||
_phoneVal,
|
||
width: fieldWidth,
|
||
keyboardType: TextInputType.phone,
|
||
inputFormatters: _phoneInputFormatters,
|
||
),
|
||
_labeledField(
|
||
'Partner Id *',
|
||
_codeCtrl,
|
||
_reqCode,
|
||
width: fieldWidth,
|
||
inputFormatters: [
|
||
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9]')),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: verticalGap),
|
||
pair(
|
||
_salesDropdown(context, fieldWidth),
|
||
_labeledField(
|
||
'Address *',
|
||
_addressCtrl,
|
||
_reqAddress,
|
||
width: fieldWidth,
|
||
keyboardType: TextInputType.streetAddress,
|
||
),
|
||
),
|
||
const SizedBox(height: verticalGap),
|
||
pair(
|
||
_certificateColumn(context),
|
||
const SizedBox.shrink(),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
String? _reqName(String? v) => Validators.requiredField(v, 'Name');
|
||
|
||
String? _reqCode(String? v) => Validators.requiredField(v, 'Partner Id');
|
||
|
||
String? _reqAddress(String? v) => Validators.requiredField(v, 'Address');
|
||
|
||
String? _emailVal(String? v) {
|
||
final email = (v ?? '').trim();
|
||
final phone = _mobileCtrl.text.trim();
|
||
if (email.isEmpty && phone.isEmpty) {
|
||
return 'Enter email or phone number';
|
||
}
|
||
if (email.isEmpty && phone.isNotEmpty) return null;
|
||
return Validators.email(email, 'Email');
|
||
}
|
||
|
||
String? _phoneVal(String? v) {
|
||
final phone = (v ?? '').trim();
|
||
final email = _emailCtrl.text.trim();
|
||
if (email.isEmpty && phone.isEmpty) {
|
||
return 'Enter email or phone number';
|
||
}
|
||
if (phone.isEmpty && email.isNotEmpty) return null;
|
||
if (!RegExp(r'^[0-9]+$').hasMatch(phone)) {
|
||
return 'Phone must contain only digits';
|
||
}
|
||
if (phone.length != 10) {
|
||
return 'Enter a valid 10-digit phone number';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Widget _labeledField(
|
||
String label,
|
||
TextEditingController c,
|
||
String? Function(String?)? validator, {
|
||
required double width,
|
||
TextInputType? keyboardType,
|
||
List<TextInputFormatter>? inputFormatters,
|
||
}) {
|
||
final w = width == double.infinity ? null : width;
|
||
final mq = MediaQuery.sizeOf(context).width;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label, style: _labelStyle),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
width: w ?? double.infinity,
|
||
child: ThemedFormField(
|
||
controller: c,
|
||
validator: validator,
|
||
keyboardType: keyboardType,
|
||
inputFormatters: inputFormatters,
|
||
borderColor: const Color(0xFFE2E8F0),
|
||
highlightColor: const Color(0xFF50A398),
|
||
txtwidth: w ?? mq,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _salesDropdown(BuildContext context, double fieldWidth) {
|
||
final w = fieldWidth == double.infinity ? null : fieldWidth;
|
||
final mq = MediaQuery.sizeOf(context).width;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Sales executive *', style: _labelStyle),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
width: w ?? double.infinity,
|
||
child: DropdownSearch<Map<String, dynamic>>(
|
||
key: _seKey,
|
||
selectedItem: _selectedSalesExecutiveMap(),
|
||
items: (f, i) => _salesExecutives,
|
||
itemAsString: (m) => (m['name'] ?? '').toString(),
|
||
compareFn: (a, b) => a['id'] == b['id'],
|
||
onChanged: (v) {
|
||
setState(() => _selectedSalesExecutiveId = v?['id']);
|
||
},
|
||
decoratorProps: DropDownDecoratorProps(
|
||
decoration: AppInputDecorations.dropdownDecoration(
|
||
label: 'Select executive',
|
||
).copyWith(
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||
),
|
||
),
|
||
popupProps: PopupProps.menu(
|
||
showSearchBox: true,
|
||
fit: FlexFit.loose,
|
||
constraints: const BoxConstraints(maxHeight: 280),
|
||
menuProps: const MenuProps(
|
||
backgroundColor: Colors.white,
|
||
),
|
||
searchFieldProps: TextFieldProps(
|
||
decoration: InputDecoration(
|
||
hintText: 'Search…',
|
||
contentPadding: const EdgeInsets.all(8),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _certificateColumn(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final w = constraints.maxWidth;
|
||
final uploadW = w.isFinite && w > 0 ? w : 240.0;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Upload certificate', style: _labelStyle),
|
||
const SizedBox(height: 8),
|
||
ThemedUploadField(
|
||
hintText: _certificateLabel ?? 'Upload document',
|
||
txtwidth: uploadW,
|
||
txtheight: 45,
|
||
borderColor: const Color(0xFFE2E8F0),
|
||
onFileSelected: (name, file) {
|
||
setState(() {
|
||
_certificateFile = file;
|
||
_certificateLabel = name;
|
||
});
|
||
},
|
||
),
|
||
if ((_certificateFile != null || _certificateUrlFromApi != null) &&
|
||
_loadedAgentPk != null) ...[
|
||
const SizedBox(height: 6),
|
||
TextButton(
|
||
onPressed: () {
|
||
_api.downloadFile(
|
||
apiUrl: 'agent/downloadAgentCertificateFile?agent_id=$_loadedAgentPk',
|
||
apiId: _loadedAgentPk,
|
||
localFile: _certificateFile,
|
||
fileName: _certificateLabel,
|
||
);
|
||
},
|
||
child: const Text('Download certificate'),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _retentionTableInCard({
|
||
required double viewportHeight,
|
||
required double parentWidth,
|
||
}) {
|
||
final tableColumn = Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_retentionTableHeader(),
|
||
..._filteredVehicleTypes.map(_retentionDataRow),
|
||
],
|
||
);
|
||
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||
),
|
||
child: SizedBox(
|
||
height: viewportHeight,
|
||
width: double.infinity,
|
||
child: Scrollbar(
|
||
thumbVisibility: true,
|
||
child: SingleChildScrollView(
|
||
scrollDirection: Axis.vertical,
|
||
primary: false,
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(minWidth: parentWidth),
|
||
child: tableColumn,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Table shell with a fixed header row; only data rows scroll (web two-column layout).
|
||
Widget _retentionTableStickyHeaderCard() {
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_retentionTableHeader(),
|
||
Expanded(
|
||
child: Scrollbar(
|
||
thumbVisibility: true,
|
||
child: ListView.builder(
|
||
padding: EdgeInsets.zero,
|
||
primary: false,
|
||
itemCount: _filteredVehicleTypes.length,
|
||
itemBuilder: (context, i) =>
|
||
_retentionDataRow(_filteredVehicleTypes[i]),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _retentionTitleSearchRow() {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'Retention rate by vehicle type',
|
||
style: GoogleFonts.poppins(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF1E293B),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 220,
|
||
child: ThemedSearchField(
|
||
hintText: 'Search vehicle type and segment',
|
||
backgroundColor: Colors.white,
|
||
txtHeight: 34,
|
||
controller: _retentionSearchCtrl,
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// [expandTableHeight]: when true (web two-column layout), the table fills remaining
|
||
/// height beside Partner Details. When false (mobile), uses list/cards and vertical stack.
|
||
///
|
||
/// [showSectionTitleRow]: when false, the title + search row is omitted (shown beside
|
||
/// Partner Details in the parent [Row]).
|
||
Widget _retentionSection(
|
||
BuildContext context, {
|
||
bool expandTableHeight = false,
|
||
bool showSectionTitleRow = true,
|
||
}) {
|
||
final mobile = ResponsiveLayout.isMobile(context);
|
||
|
||
if (mobile) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Retention rate by vehicle type',
|
||
style: GoogleFonts.poppins(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF1E293B),
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
ThemedSearchField(
|
||
hintText: 'Search vehicle type',
|
||
backgroundColor: Colors.white,
|
||
txtHeight: 36,
|
||
txtwidth: double.infinity,
|
||
controller: _retentionSearchCtrl,
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
_retentionMobileList(context),
|
||
const SizedBox(height: 12),
|
||
_retentionSaveAllButtonBottom(context),
|
||
],
|
||
);
|
||
}
|
||
|
||
if (!showSectionTitleRow) {
|
||
assert(expandTableHeight, 'showSectionTitleRow false is only used with expandTableHeight');
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(child: _retentionTableStickyHeaderCard()),
|
||
const SizedBox(height: 12),
|
||
_retentionSaveAllButtonBottom(context),
|
||
],
|
||
);
|
||
}
|
||
|
||
final Widget tableArea;
|
||
if (expandTableHeight) {
|
||
tableArea = Expanded(
|
||
child: _retentionTableStickyHeaderCard(),
|
||
);
|
||
} else {
|
||
tableArea = LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final parentW = constraints.maxWidth.isFinite
|
||
? constraints.maxWidth
|
||
: MediaQuery.sizeOf(context).width;
|
||
final screenH = MediaQuery.sizeOf(context).height;
|
||
final viewportH = math.min(520.0, math.max(240.0, screenH * 0.42));
|
||
return _retentionTableInCard(viewportHeight: viewportH, parentWidth: parentW);
|
||
},
|
||
);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_retentionTitleSearchRow(),
|
||
const SizedBox(height: 12),
|
||
tableArea,
|
||
const SizedBox(height: 12),
|
||
_retentionSaveAllButtonBottom(context),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _retentionSaveAllButtonBottom(BuildContext context) {
|
||
final mobile = ResponsiveLayout.isMobile(context);
|
||
return Align(
|
||
alignment: mobile ? Alignment.center : Alignment.centerRight,
|
||
child: FilledButton(
|
||
onPressed: _savingAllRetention
|
||
? null
|
||
: (_retentionTableEnabled && !_retentionActionsBusy)
|
||
? _saveAllRetentionRates
|
||
: null,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: const Color(0xFF2E7D6E),
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: mobile ? 20 : 24,
|
||
vertical: 12,
|
||
),
|
||
),
|
||
child: _savingAllRetention
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: const Text('Save all retention rates'),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Stacked cards on narrow screens (no forced horizontal scroll).
|
||
Widget _retentionMobileList(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
for (final vt in _filteredVehicleTypes) _retentionMobileCard(vt),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _retentionMobileCard(Map<String, dynamic> vt) {
|
||
final vehicleTypeId = _readInt(vt['vehicle_type_id'] ?? vt['id']);
|
||
final segmentId = _readInt(vt['segment_id']);
|
||
final key = _retentionKey(vehicleTypeId, segmentId);
|
||
final name = _displayVehicleSegment(vt);
|
||
final ctrl = _rateControllers[key];
|
||
if (ctrl == null) return const SizedBox.shrink();
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(name, style: _headerStyle),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: ctrl,
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
inputFormatters: _retentionPercentFormatters,
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
hintText: '0–100%',
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: TextButton(
|
||
onPressed: (_retentionTableEnabled && !_retentionActionsBusy)
|
||
? () => _saveRowRetention(vt)
|
||
: null,
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: const Color(0xFF2E7D6E),
|
||
backgroundColor: const Color(0xFFE8ECF0),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
),
|
||
child: const Text('UPDATE'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _retentionTableHeader() {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
|
||
decoration: const BoxDecoration(
|
||
color: Color(0xFFEDF2F9),
|
||
border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0))),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
flex: 5,
|
||
child: Text(
|
||
'Vehicle type / Segment',
|
||
style: _headerStyle,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
Expanded(
|
||
flex: 3,
|
||
child: Text(
|
||
'Retention %',
|
||
style: _headerStyle,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 88,
|
||
child: Text(
|
||
'Action',
|
||
style: _headerStyle,
|
||
textAlign: TextAlign.center,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _retentionDataRow(Map<String, dynamic> vt) {
|
||
final vehicleTypeId = _readInt(vt['vehicle_type_id'] ?? vt['id']);
|
||
final segmentId = _readInt(vt['segment_id']);
|
||
final key = _retentionKey(vehicleTypeId, segmentId);
|
||
final name = _displayVehicleSegment(vt);
|
||
final ctrl = _rateControllers[key];
|
||
if (ctrl == null) return const SizedBox.shrink();
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Expanded(
|
||
flex: 5,
|
||
child: Text(
|
||
name,
|
||
style: _cellStyle,
|
||
overflow: TextOverflow.ellipsis,
|
||
maxLines: 2,
|
||
),
|
||
),
|
||
Expanded(
|
||
flex: 3,
|
||
child: TextField(
|
||
controller: ctrl,
|
||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||
inputFormatters: _retentionPercentFormatters,
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
hintText: '0–100%',
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 88,
|
||
child: Center(
|
||
child: TextButton(
|
||
onPressed: (_retentionTableEnabled && !_retentionActionsBusy)
|
||
? () => _saveRowRetention(vt)
|
||
: null,
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: const Color(0xFF2E7D6E),
|
||
backgroundColor: const Color(0xFFE8ECF0),
|
||
minimumSize: Size.zero,
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
),
|
||
child: const Text('UPDATE', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
static final _labelStyle = GoogleFonts.poppins(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: const Color(0xFF334155),
|
||
);
|
||
|
||
static final _headerStyle = GoogleFonts.poppins(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF1E293B),
|
||
);
|
||
|
||
static final _cellStyle = GoogleFonts.inter(fontSize: 12, color: const Color(0xFF0F172A));
|
||
}
|