Skip to content

Instantly share code, notes, and snippets.

@tatd3v
Last active February 10, 2026 20:16
Show Gist options
  • Select an option

  • Save tatd3v/3eeadfcfbf4d1e2f375c38ea860643f9 to your computer and use it in GitHub Desktop.

Select an option

Save tatd3v/3eeadfcfbf4d1e2f375c38ea860643f9 to your computer and use it in GitHub Desktop.
4step-add-client
import 'dart:convert';
import 'package:admin2/global/global_constantes.dart';
import 'package:admin2/services/auth_service.dart';
import 'package:admin2/toolbox/auto_filled.dart';
import 'package:admin2/toolbox/ubicacion_widget.dart';
import 'package:admin2/toolbox/my_custom_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:intl_phone_field/intl_phone_field.dart';
import 'package:intl_phone_field/country_picker_dialog.dart';
class ClientAddDialog extends StatefulWidget {
const ClientAddDialog({super.key});
@override
State<ClientAddDialog> createState() => _ClientAddDialogState();
}
class _ClientAddDialogState extends State<ClientAddDialog> {
final _formKey = GlobalKey<FormState>();
final PageController _pageController = PageController();
int _currentStep = 0;
bool _isSaving = false;
// Step 1: Información Personal
String _tipoDocumento = 'DNI / Cédula';
final TextEditingController _numeroIDCtrl = TextEditingController();
final TextEditingController _numeroIDInternoCtrl = TextEditingController();
final TextEditingController _nombreCtrl = TextEditingController();
final TextEditingController _apellidoCtrl = TextEditingController();
String _sexo = 'Masculino';
DateTime? _fechaNacimiento;
String _estadoCivil = 'Soltero/a';
final TextEditingController _ocupacionCtrl = TextEditingController();
// Step 2: Contacto
final TextEditingController _direccionCtrl = TextEditingController();
final TextEditingController _emailCtrl = TextEditingController();
String _preferenciaComunicacion = 'Email';
String _completePhoneNumber = ''; // Stores full phone with country code
// Location fields
final TextEditingController _idprovinciaController = TextEditingController(text: '0');
final TextEditingController _iddistritoController = TextEditingController(text: '0');
final TextEditingController _idcorregimientoController = TextEditingController(text: '0');
final MyCustomController _nacionalidadController = MyCustomController();
// Step 3: Información Profesional
final TextEditingController _empresaCtrl = TextEditingController();
final TextEditingController _puestoCtrl = TextEditingController();
DateTime? _fechaIngreso;
final TextEditingController _antiguedadCtrl = TextEditingController();
final TextEditingController _salarioCtrl = TextEditingController();
final TextEditingController _centroCostoCtrl = TextEditingController();
// Step 4: Perfil de Cliente
String _perfilRiesgo = 'Bajo';
int _rankingCliente = 4;
DateTime _fechaRegistro = DateTime.now();
final List<String> _stepTitles = ['Información', 'Contacto', 'Profesional', 'Perfil'];
@override
void dispose() {
_pageController.dispose();
_nombreCtrl.dispose();
_apellidoCtrl.dispose();
_ocupacionCtrl.dispose();
_numeroIDCtrl.dispose();
_numeroIDInternoCtrl.dispose();
_emailCtrl.dispose();
_direccionCtrl.dispose();
_empresaCtrl.dispose();
_puestoCtrl.dispose();
_antiguedadCtrl.dispose();
_salarioCtrl.dispose();
_centroCostoCtrl.dispose();
// Dispose location controllers
_idprovinciaController.dispose();
_iddistritoController.dispose();
_idcorregimientoController.dispose();
_nacionalidadController.dispose();
super.dispose();
}
void _nextStep() {
if (_currentStep < 3) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _prevStep() {
if (_currentStep > 0) {
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
Navigator.of(context).pop();
}
}
Future<void> _selectDate(BuildContext context, DateTime? current, ValueChanged<DateTime> onSelected) async {
final picked = await showDatePicker(
context: context,
initialDate: current ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime(2100),
);
if (picked != null) onSelected(picked);
}
InputDecoration _inputDecoration(String label) {
return InputDecoration(
labelText: label,
filled: true,
fillColor: Theme.of(context).cardColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).dividerColor),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).dividerColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth > 600 ? 450.0 : screenWidth * 0.9;
return Dialog(
backgroundColor: theme.scaffoldBackgroundColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: SizedBox(
width: dialogWidth,
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: theme.dividerColor)),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 18),
onPressed: _prevStep,
),
Expanded(
child: Text(
'Nuevo Cliente',
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 48),
],
),
),
// Stepper Indicator
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: List.generate(4, (index) {
final isActive = index <= _currentStep;
final isCurrent = index == _currentStep;
return Expanded(
child: Column(
children: [
Container(
height: 3,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: isActive ? theme.colorScheme.primary : theme.dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 6),
Text(
_stepTitles[index],
style: TextStyle(
fontSize: 10,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.w500,
color: isCurrent ? theme.colorScheme.primary : theme.hintColor,
letterSpacing: 0.5,
),
),
],
),
);
}),
),
),
// Form Content
Expanded(
child: Form(
key: _formKey,
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (index) => setState(() => _currentStep = index),
children: [
_buildStep1(),
_buildStep2(),
_buildStep3(),
_buildStep4(),
],
),
),
),
// Footer Navigation
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: theme.dividerColor)),
),
child: Row(
children: [
if (_currentStep > 0)
Expanded(
child: OutlinedButton(
onPressed: _prevStep,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Anterior', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
),
),
if (_currentStep > 0) const SizedBox(width: 12),
Expanded(
flex: _currentStep == 0 ? 1 : 2,
child: ElevatedButton(
onPressed: _isSaving ? null : (_currentStep == 3 ? _guardarCliente : _nextStep),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_currentStep == 3 ? 'Finalizar' : 'Siguiente',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
if (_currentStep < 3) ...[
const SizedBox(width: 6),
const Icon(Icons.arrow_forward, size: 16),
],
],
),
),
),
],
),
),
],
),
),
);
}
// ─── Step 1: Información Personal ───
Widget _buildStep1() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Información Personal',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Paso 1 de 4: Datos de identificación básica.',
style: TextStyle(color: Theme.of(context).hintColor, fontSize: 13),
),
const SizedBox(height: 24),
// Tipo de Identificación
DropdownButtonFormField<String>(
value: _tipoDocumento,
decoration: _inputDecoration('Tipo de Identificación'),
items: ['DNI / Cédula', 'Pasaporte', 'Licencia de Conducir']
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (v) => setState(() => _tipoDocumento = v!),
),
const SizedBox(height: 16),
// Número de Identificación
TextFormField(
controller: _numeroIDCtrl,
decoration: _inputDecoration('Número de Identificación'),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
const SizedBox(height: 16),
// ID Interno
TextFormField(
controller: _numeroIDInternoCtrl,
decoration: _inputDecoration('Número ID Interno'),
),
const Divider(height: 32),
// Nombres
TextFormField(
controller: _nombreCtrl,
decoration: _inputDecoration('Nombre(s)'),
validator: (v) => v == null || v.isEmpty ? 'Requerido' : null,
),
const SizedBox(height: 16),
// Apellidos
TextFormField(
controller: _apellidoCtrl,
decoration: _inputDecoration('Apellido(s)'),
validator: (v) => v == null || v.isEmpty ? 'Requerido' : null,
),
const SizedBox(height: 16),
// Sexo & Fecha Nacimiento
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
value: _sexo,
decoration: _inputDecoration('Sexo'),
items: ['Masculino', 'Femenino', 'Otro']
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (v) => setState(() => _sexo = v!),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
readOnly: true,
decoration: _inputDecoration('Fecha Nacimiento'),
controller: TextEditingController(
text: _fechaNacimiento != null ? DateFormat('yyyy-MM-dd').format(_fechaNacimiento!) : '',
),
onTap: () => _selectDate(context, _fechaNacimiento, (d) => setState(() => _fechaNacimiento = d)),
),
),
],
),
const SizedBox(height: 16),
// Estado Civil
DropdownButtonFormField<String>(
value: _estadoCivil,
decoration: _inputDecoration('Estado Civil'),
items: ['Soltero/a', 'Casado/a', 'Divorciado/a', 'Viudo/a', 'Unión Libre']
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (v) => setState(() => _estadoCivil = v!),
),
const SizedBox(height: 16),
// Ocupación
TextFormField(
controller: _ocupacionCtrl,
decoration: _inputDecoration('Ocupación'),
),
const SizedBox(height: 24),
],
);
}
// ─── Step 2: Contacto ───
Widget _buildStep2() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Información de Contacto',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Paso 2 de 4: Datos de contacto del cliente.',
style: TextStyle(color: Theme.of(context).hintColor, fontSize: 13),
),
const SizedBox(height: 24),
// Dirección
TextFormField(
controller: _direccionCtrl,
decoration: _inputDecoration('Dirección'),
),
const SizedBox(height: 16),
// Teléfono con selector de código de área (todos los países)
Container(
child: IntlPhoneField(
decoration: _inputDecoration('Teléfono'),
initialCountryCode: 'PA', // Panama default
disableLengthCheck: true,
showCountryFlag: false,
dropdownTextStyle: TextStyle(fontSize: 12),
pickerDialogStyle: PickerDialogStyle(
width: 350,
countryNameStyle: TextStyle(fontSize: 14),
countryCodeStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
padding: EdgeInsets.all(8),
listTilePadding: EdgeInsets.symmetric(horizontal: 8, vertical: 0),
searchFieldInputDecoration: InputDecoration(
suffixIcon: const Icon(Icons.search),
labelText: 'Buscar país',
),
),
onChanged: (phone) {
_completePhoneNumber = phone.completeNumber;
},
onSaved: (phone) {
if (phone != null) {
_completePhoneNumber = phone.completeNumber;
}
},
),
),
const SizedBox(height: 16),
// Email
TextFormField(
controller: _emailCtrl,
decoration: _inputDecoration('Correo Electrónico'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
// Ubicación (Provincia, Distrito, Corregimiento)
Text(
'Ubicación',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Theme.of(context).hintColor),
),
const SizedBox(height: 12),
ubicacionwidget(
provController: _idprovinciaController,
distController: _iddistritoController,
corrController: _idcorregimientoController,
idempleado: 0,
),
const SizedBox(height: 16),
// Nacionalidad
Text(
'Nacionalidad/País de Residencia:',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Theme.of(context).hintColor),
),
const SizedBox(height: 8),
autofield2(
etiqueta: 'Nacionalidad',
urllink: Constants.urlsselectorpais,
dataController: _nacionalidadController,
idcampo: 'idpais',
nombrecampo: 'nombre',
usadatosgenericos: false,
),
const SizedBox(height: 16),
// Preferencia de Comunicación
Text(
'Preferencia de Comunicación',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Theme.of(context).hintColor),
),
const SizedBox(height: 12),
Row(
children: [
_commPrefButton('Email', Icons.mail),
const SizedBox(width: 8),
_commPrefButton('Llamada Telefónica', Icons.call),
const SizedBox(width: 8),
_commPrefButton('WhatsApp', Icons.chat),
],
),
const SizedBox(height: 24),
],
);
}
Widget _commPrefButton(String label, IconData icon) {
final isSelected = _preferenciaComunicacion == label;
final theme = Theme.of(context);
return Expanded(
child: InkWell(
onTap: () => setState(() => _preferenciaComunicacion = label),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? theme.colorScheme.primary : theme.dividerColor,
width: isSelected ? 2 : 1,
),
color: isSelected ? theme.colorScheme.primary.withOpacity(0.1) : theme.cardColor,
),
child: Column(
children: [
Icon(icon, size: 24, color: isSelected ? theme.colorScheme.primary : theme.hintColor),
const SizedBox(height: 4),
Text(
label.split(' ').first,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isSelected ? theme.colorScheme.primary : theme.hintColor,
),
),
],
),
),
),
);
}
// ─── Step 3: Información Profesional ───
Widget _buildStep3() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Información Profesional',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Paso 3 de 4: Detalles laborales del cliente.',
style: TextStyle(color: Theme.of(context).hintColor, fontSize: 13),
),
const SizedBox(height: 24),
// Empresa
TextFormField(
controller: _empresaCtrl,
decoration: _inputDecoration('Empresa'),
),
const SizedBox(height: 16),
// Puesto
TextFormField(
controller: _puestoCtrl,
decoration: _inputDecoration('Puesto / Cargo'),
),
const SizedBox(height: 16),
// Fecha Ingreso & Antigüedad
Row(
children: [
Expanded(
child: TextFormField(
readOnly: true,
decoration: _inputDecoration('Fecha de Ingreso'),
controller: TextEditingController(
text: _fechaIngreso != null ? DateFormat('yyyy-MM-dd').format(_fechaIngreso!) : '',
),
onTap: () => _selectDate(context, _fechaIngreso, (d) => setState(() => _fechaIngreso = d)),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _antiguedadCtrl,
decoration: _inputDecoration('Antigüedad (Años)'),
keyboardType: TextInputType.number,
),
),
],
),
const SizedBox(height: 16),
// Salario
TextFormField(
controller: _salarioCtrl,
decoration: _inputDecoration('Salario Mensual').copyWith(prefixText: '\$ '),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Centro de Costo
TextFormField(
controller: _centroCostoCtrl,
decoration: _inputDecoration('Centro de Costo (ID)'),
),
const SizedBox(height: 24),
],
);
}
// ─── Step 4: Perfil de Cliente ───
Widget _buildStep4() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Perfil y Clasificación',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Paso 4 de 4: Define el nivel de riesgo y categoría comercial.',
style: TextStyle(color: Theme.of(context).hintColor, fontSize: 13),
),
const SizedBox(height: 24),
// Perfil de Riesgo
DropdownButtonFormField<String>(
value: _perfilRiesgo,
decoration: _inputDecoration('Perfil de Riesgo'),
items: ['Bajo', 'Medio', 'Alto', 'Muy Alto / PEP']
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (v) => setState(() => _perfilRiesgo = v!),
),
const SizedBox(height: 16),
// Ranking del Cliente
DropdownButtonFormField<int>(
value: _rankingCliente,
decoration: _inputDecoration('Ranking del Cliente'),
items: const [
DropdownMenuItem(value: 5, child: Text('Platinum (Prioridad A+)')),
DropdownMenuItem(value: 4, child: Text('Gold (Prioridad A)')),
DropdownMenuItem(value: 3, child: Text('Silver (Prioridad B)')),
DropdownMenuItem(value: 2, child: Text('Bronze (Prioridad C)')),
DropdownMenuItem(value: 1, child: Text('Basic')),
],
onChanged: (v) => setState(() => _rankingCliente = v!),
),
const SizedBox(height: 16),
// Fecha de Registro
TextFormField(
readOnly: true,
decoration: _inputDecoration('Fecha de Registro'),
controller: TextEditingController(text: DateFormat('yyyy-MM-dd').format(_fechaRegistro)),
onTap: () => _selectDate(context, _fechaRegistro, (d) => setState(() => _fechaRegistro = d)),
),
const SizedBox(height: 8),
Text(
'Esta fecha se utilizará para el inicio del ciclo de facturación.',
style: TextStyle(fontSize: 11, color: Theme.of(context).hintColor),
),
const SizedBox(height: 24),
],
);
}
Future<void> _guardarCliente() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSaving = true);
try {
final body = {
'TipoID': _tipoDocumento,
'NumeroID': _numeroIDCtrl.text,
'NumeroIDInterno': _numeroIDInternoCtrl.text,
'Nombre': _nombreCtrl.text,
'Apellido': _apellidoCtrl.text,
'Direccion': _direccionCtrl.text,
'Telefono': _completePhoneNumber,
'Email': _emailCtrl.text,
'FechaRegistro': _fechaRegistro.toIso8601String(),
'Sexo': _sexo,
'FechaNacimiento': (_fechaNacimiento ?? DateTime.now()).toIso8601String(),
'idprovincia': _idprovinciaController.text.isEmpty ? 0 : int.tryParse(_idprovinciaController.text) ?? 0,
'iddistrito': _iddistritoController.text.isEmpty ? 0 : int.tryParse(_iddistritoController.text) ?? 0,
'idcorregimiento': _idcorregimientoController.text.isEmpty ? 0 : int.tryParse(_idcorregimientoController.text) ?? 0,
'idpais': _nacionalidadController.valorLockup?.toString().isNotEmpty == true
? int.tryParse(_nacionalidadController.valorLockup.toString()) ?? 1
: 1, // Default to Panama if empty
'Ocupacion': _ocupacionCtrl.text,
'EstadoCivil': _estadoCivil,
'PreferenciaComunicacion': _preferenciaComunicacion,
'PerfilRiesgo': _perfilRiesgo,
'RankingCliente': _rankingCliente,
'Salario': double.tryParse(_salarioCtrl.text) ?? 0,
'Antiguedad': int.tryParse(_antiguedadCtrl.text) ?? 0,
'Empresa': _empresaCtrl.text,
'Puesto': _puestoCtrl.text,
'FechaIngreso': (_fechaIngreso ?? DateTime.now()).toIso8601String(),
'idCentroCosto': _centroCostoCtrl.text,
'CodigoPostal': '',
'ObjetivosFinancieros': '',
'CampoDato1': '',
'CampoDato2': '',
'CampoDato3': '',
'CampoDato4': '',
'CampoDato5': '',
'CampoDato6': '',
'CampoDato7': '',
'CampoDato9': '',
};
final String apiUrl = '${Constants.serverapp}/clientes/';
final token = await AuthService.getToken();
final response = await http.post(
Uri.parse(apiUrl),
headers: {
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': 'Bearer $token',
},
body: jsonEncode(body),
);
if (response.statusCode == 200 || response.statusCode == 201) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cliente registrado correctamente'), backgroundColor: Colors.green),
);
Navigator.of(context).pop(true);
}
} else {
final msg = jsonDecode(response.body)['message'] ?? 'Error desconocido';
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $msg'), backgroundColor: Colors.red),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment