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 createState() => _AgentDetailsScreenState(); } class _AgentDetailsScreenState extends ConsumerState { /// Digits only, max 10 (India-style mobile). static final List _phoneInputFormatters = [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), ]; /// Percentage 0–100 with up to 2 decimal places; cannot exceed 100. static final List _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(); final ApiService _api = ApiService(); final _nameCtrl = TextEditingController(); final _emailCtrl = TextEditingController(); final _mobileCtrl = TextEditingController(); final _codeCtrl = TextEditingController(); final _addressCtrl = TextEditingController(); final _retentionSearchCtrl = TextEditingController(); final GlobalKey>> _seKey = GlobalKey>>(); List> _vehicleTypes = []; final Map _rateControllers = {}; List> _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 _reloadAgentForRouteIdChange() async { await _loadAgent(); if (mounted) setState(() {}); } Future _bootstrap() async { _token = await AuthService.getToken(); await _loadSalesExecutives(); await _loadVehicleTypes(); if (!widget.isCreate) { await _loadAgent(); } else { _initRateControllersFromTypes(); } if (mounted) setState(() => _loading = false); } Future _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>.from(res['data']); setState(() { _salesExecutives = list.where((e) => int.tryParse(e['is_active'].toString()) == 1).toList(); }); } } catch (_) {} } Future _loadVehicleTypes() async { try { final res = await _api.fetchPartnerVehicleTypes(); if (res['status'] == 'success' && res['data'] is List) { setState(() { _vehicleTypes = List>.from(res['data']); }); } } catch (_) {} } String _retentionKey(int vehicleTypeId, int segmentId) => '${vehicleTypeId}_$segmentId'; int _readInt(dynamic value) => int.tryParse(value?.toString() ?? '') ?? 0; String _displayVehicleSegment(Map vt) { final name = (vt['vehicle_type'] ?? '-').toString(); final segment = (vt['segment'] ?? '').toString().trim(); if (segment.isEmpty) return name; return '$name - $segment'; } void _initRateControllersFromTypes({Map? 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 _retentionMapFromAgentData(Map data) { final Map 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.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 _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.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> 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> _collectBulkRetentionPayload() { final out = >[]; 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 _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.from(body); final data = m['data']; if (data is Map) { final dm = Map.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 _saveRowRetention(Map 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 _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? _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? 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>( 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 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 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)); }