nhance_partner/lib/presentation/screens/UserManagement/Agent/agent_details.dart

1477 lines
49 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 pervehicle-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 0100 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 (0100%)',
);
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: '0100%',
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: '0100%',
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));
}