import 'dart:async'; import 'package:sysfarma_mobile/src/app/widgets/app_loader.dart'; import 'package:sysfarma_mobile/src/app/widgets/empty_state_panel.dart'; import 'package:sysfarma_mobile/src/core/network/api_client.dart'; import 'package:sysfarma_mobile/src/core/network/mobile_api_response.dart'; import 'package:sysfarma_mobile/src/core/storage/recent_selections_store.dart'; import 'package:sysfarma_mobile/src/core/theme/app_tokens.dart'; import 'package:sysfarma_mobile/src/core/widgets/paginated_list_mixin.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher_string.dart'; enum PersonScreenType { customers, suppliers } class PersonsScreen extends ConsumerStatefulWidget { const PersonsScreen({ required this.type, super.key, }); final PersonScreenType type; @override ConsumerState createState() => _PersonsScreenState(); } class _PersonsScreenState extends ConsumerState with PaginatedListMixin { final _searchController = TextEditingController(); final _scrollController = ScrollController(); Timer? _searchDebounce; List> _recents = const []; bool _loading = true; String? _error; bool get _isCustomer => widget.type == PersonScreenType.customers; String get _scope => _isCustomer ? 'customers' : 'suppliers'; String get _title => _isCustomer ? 'Clientes' : 'Proveedores'; @override void initState() { super.initState(); initPagination(_scrollController); onLoadPage = _fetchPage; _bootstrap(); } @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); _scrollController.dispose(); disposePagination(); super.dispose(); } void _onSearchChanged(String value) { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 350), () { if (mounted) _loadInitial(); }); } Future _bootstrap() async { _recents = await ref.read(recentSelectionsStoreProvider).read(_scope); if (mounted) { setState(() {}); } await _loadInitial(); } Future>>> _fetchPage(int page) { if (_isCustomer) { return ref.read(apiClientProvider).customers( page: page, perPage: 12, search: _searchController.text.trim(), ); } return ref.read(apiClientProvider).suppliers( page: page, perPage: 12, search: _searchController.text.trim(), ); } Future _loadInitial() async { setState(() { _loading = true; _error = null; }); final response = await _fetchPage(1); if (!mounted) { return; } if (_handleAuthFailure(response)) { return; } if (!response.success) { setState(() { _loading = false; _error = response.message ?? 'No se pudo cargar el listado.'; }); return; } setState(() { _loading = false; _error = null; resetPagination( firstPageItems: response.data ?? const [], firstPageMeta: response.meta, ); }); } bool _handleAuthFailure(MobileApiResponse response) { if (response.errors['auth'] == true && mounted) { context.go('/login'); return true; } return false; } Future _showEditor({Map? record}) async { final detail = record == null ? null : (_isCustomer ? await ref .read(apiClientProvider) .customerDetail(id: int.tryParse('${record['id']}') ?? 0) : await ref .read(apiClientProvider) .supplierDetail(id: int.tryParse('${record['id']}') ?? 0)); if (detail != null && _handleAuthFailure(detail)) { return; } final source = Map.from( detail?.data ?? record ?? const {}, ); final nameController = TextEditingController(text: '${source['name'] ?? ''}'); final numberController = TextEditingController(text: '${source['number'] ?? ''}'); final phoneController = TextEditingController(text: '${source['telephone'] ?? ''}'); final emailController = TextEditingController(text: '${source['email'] ?? ''}'); final addressController = TextEditingController(text: '${source['address'] ?? ''}'); final formKey = GlobalKey(); var identityType = '${source['identity_document_type_id'] ?? '1'}'; String? errorText; var saving = false; if (!mounted) { return; } await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { return StatefulBuilder( builder: (context, setSheetState) { var lookingUp = false; int? expectedLength() { if (identityType == '1') return 8; if (identityType == '6') return 11; return null; } String? numberValidator(String? value) { final text = (value ?? '').trim(); if (text.isEmpty) return 'Ingresa el número de documento.'; final len = expectedLength(); if (len != null) { if (text.length != len) { return identityType == '1' ? 'El DNI debe tener 8 dígitos.' : 'El RUC debe tener 11 dígitos.'; } if (!RegExp(r'^\d+$').hasMatch(text)) { return 'Solo se permiten dígitos.'; } } return null; } Future lookupPerson() async { final number = numberController.text.trim(); final len = expectedLength(); if (len == null || number.length != len) return; if (!RegExp(r'^\d+$').hasMatch(number)) return; setSheetState(() { lookingUp = true; errorText = null; }); final type = identityType == '1' ? 'dni' : 'ruc'; final response = await ref .read(apiClientProvider) .lookupPersonByDocument(type: type, number: number); if (!context.mounted) return; if (response.success && response.data != null) { final data = response.data!; setSheetState(() { lookingUp = false; if ((data['name'] ?? '').toString().isNotEmpty) { nameController.text = data['name'].toString(); } final addr = data['address']?.toString() ?? ''; if (addr.isNotEmpty && addressController.text.isEmpty) { addressController.text = addr; } final phone = data['phone']?.toString() ?? ''; if (phone.isNotEmpty && phoneController.text.isEmpty) { phoneController.text = phone; } }); } else { setSheetState(() { lookingUp = false; errorText = response.message ?? 'No se pudo consultar.'; }); } } Future submit() async { if (!(formKey.currentState?.validate() ?? false) || saving) { return; } setSheetState(() { saving = true; errorText = null; }); final payload = { 'type': _scope, 'name': nameController.text.trim(), 'number': numberController.text.trim(), 'identity_document_type_id': identityType, 'country_id': 'PE', 'telephone': phoneController.text.trim(), 'email': emailController.text.trim(), 'address': addressController.text.trim(), }; final response = _isCustomer ? await ref.read(apiClientProvider).upsertCustomer( id: int.tryParse('${source['id'] ?? ''}'), payload: payload, ) : await ref.read(apiClientProvider).upsertSupplier( id: int.tryParse('${source['id'] ?? ''}'), payload: payload, ); if (!context.mounted) { return; } if (_handleAuthFailure(response)) { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } return; } if (!response.success) { setSheetState(() { saving = false; errorText = response.message ?? 'No se pudo guardar.'; }); return; } final saved = Map.from( response.data?['record'] as Map? ?? payload, ); await ref.read(recentSelectionsStoreProvider).push(_scope, { 'id': saved['id'], 'label': saved['name'] ?? payload['name'], 'subtitle': saved['number'] ?? payload['number'], }); _recents = await ref.read(recentSelectionsStoreProvider).read(_scope); if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Guardado correctamente.')), ); if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } await _loadInitial(); } return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 12, 20, 24 + MediaQuery.of(context).viewInsets.bottom, ), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( source['id'] == null ? 'Nuevo $_title' : 'Editar $_title', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 18), DropdownButtonFormField( initialValue: identityType, decoration: const InputDecoration( labelText: 'Tipo de documento', prefixIcon: Icon(Icons.badge_rounded), ), items: const [ DropdownMenuItem(value: '1', child: Text('DNI')), DropdownMenuItem(value: '6', child: Text('RUC')), DropdownMenuItem(value: '0', child: Text('Otros')), ], onChanged: (value) { setSheetState(() => identityType = value ?? '1'); if (numberController.text.trim().isNotEmpty) { lookupPerson(); } }, ), const SizedBox(height: 14), TextFormField( controller: numberController, keyboardType: TextInputType.number, maxLength: expectedLength(), decoration: InputDecoration( labelText: 'Documento', prefixIcon: const Icon(Icons.numbers_rounded), suffixIcon: lookingUp ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ), ) : IconButton( icon: const Icon(Icons.search_rounded), onPressed: lookupPerson, ), counterText: '', helperText: identityType == '1' ? '8 dígitos' : identityType == '6' ? '11 dígitos' : null, ), onChanged: (value) { final len = expectedLength(); if (len != null && value.trim().length == len) { lookupPerson(); } }, validator: numberValidator, ), const SizedBox(height: 14), TextFormField( controller: nameController, decoration: const InputDecoration( labelText: 'Nombre o razón social', prefixIcon: Icon(Icons.person_rounded), ), validator: (value) => (value == null || value.trim().isEmpty) ? 'Ingresa el nombre o razón social.' : null, ), const SizedBox(height: 14), TextFormField( controller: phoneController, decoration: const InputDecoration( labelText: 'Teléfono', prefixIcon: Icon(Icons.phone_rounded), ), ), const SizedBox(height: 14), TextFormField( controller: emailController, decoration: const InputDecoration( labelText: 'Correo', prefixIcon: Icon(Icons.mail_outline_rounded), ), ), const SizedBox(height: 14), TextFormField( controller: addressController, decoration: const InputDecoration( labelText: 'Dirección', prefixIcon: Icon(Icons.location_on_outlined), ), ), if (errorText != null) ...[ const SizedBox(height: 12), Text( errorText!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.danger, ), ), ], const SizedBox(height: 18), FilledButton.icon( onPressed: saving ? null : submit, icon: saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.save_rounded), label: Text(saving ? 'Guardando...' : 'Guardar'), ), ], ), ), ), ); }, ); }, ); } Future _launchContact(String url) async { if (url.isEmpty) { return; } await launchUrlString(url); } @override Widget build(BuildContext context) { final records = List>.from(paginatedItems); return SafeArea( child: Stack( children: [ RefreshIndicator( onRefresh: _loadInitial, child: ListView( controller: _scrollController, padding: AppTokens.pagePadding, children: [ // Buscador libre arriba. TextField( controller: _searchController, textInputAction: TextInputAction.search, onChanged: _onSearchChanged, onSubmitted: (_) => _loadInitial(), decoration: InputDecoration( labelText: 'Buscar por nombre, RUC o DNI', prefixIcon: const Icon(Icons.search_rounded), suffixIcon: _searchController.text.isNotEmpty ? IconButton( onPressed: () { _searchController.clear(); _loadInitial(); }, icon: const Icon(Icons.clear_rounded), ) : null, ), ), // Recientes como chips horizontales. if (_recents.isNotEmpty) ...[ const SizedBox(height: 12), SizedBox( height: 36, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: _recents.take(6).length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, i) { final item = _recents[i]; return ActionChip( avatar: const Icon(Icons.history_rounded, size: 16), label: Text( item['label']?.toString() ?? '-', style: Theme.of(context).textTheme.labelSmall, ), onPressed: () { _searchController.text = item['subtitle'] ?.toString() .isNotEmpty == true ? item['subtitle'].toString() : item['label']?.toString() ?? ''; _loadInitial(); }, ); }, ), ), ], const SizedBox(height: 8), // Conteo if (!_loading && _error == null && records.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( '${records.length} ${_isCustomer ? "cliente(s)" : "proveedor(es)"}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), ), if (_loading) const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: AppLoader(), ) else if (_error != null) EmptyStatePanel( icon: Icons.cloud_off_rounded, title: 'No disponible', subtitle: _error!, ) else if (records.isEmpty) Column( children: [ EmptyStatePanel( icon: _isCustomer ? Icons.people_outline_rounded : Icons.local_shipping_outlined, title: 'Sin registros', subtitle: _searchController.text.trim().isEmpty ? 'Aún no hay $_title registrados.' : 'No se encontró ningún resultado.', ), if (_searchController.text.trim().isNotEmpty) ...[ const SizedBox(height: 12), FilledButton.icon( onPressed: () => _showEditor(), icon: const Icon(Icons.add_rounded), label: Text('Crear nuevo $_title'), ), ], ], ) else ...records.map(_buildPersonCard), buildLoadMoreIndicator(), const SizedBox(height: 80), ], ), ), // FAB para crear. Positioned( right: 20, bottom: 92, child: FloatingActionButton.extended( onPressed: () => _showEditor(), icon: const Icon(Icons.add_rounded), label: Text(_isCustomer ? 'Nuevo cliente' : 'Nuevo proveedor'), ), ), ], ), ); } Widget _buildPersonCard(Map person) { final name = person['name']?.toString() ?? '-'; final phone = person['telephone']?.toString() ?? ''; final hasPhone = phone.isNotEmpty && phone != '-'; final rawDigits = phone.replaceAll(RegExp(r'[^0-9]'), ''); final waNumber = rawDigits.length == 9 ? '51$rawDigits' : rawDigits; final hasWa = hasPhone && waNumber.isNotEmpty; final email = person['email']?.toString() ?? ''; final hasEmail = email.isNotEmpty && email != '-'; final address = person['address']?.toString() ?? ''; final hasAddress = address.isNotEmpty && address != '-'; final docTypeId = person['identity_document_type_id']?.toString() ?? ''; final docTypeShort = _docTypeShort(docTypeId); final docNumber = person['number']?.toString() ?? '-'; final isInactive = person['active'] == false || person['state'] == false; final isVarios = docNumber == '99999999' || docNumber == '00000000'; final initials = _initialsFor(name); final avatarColor = _colorForName(name); return Padding( padding: const EdgeInsets.only(bottom: 8), child: Material( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: InkWell( onTap: () => _showEditor(record: person), onLongPress: () => _showQuickActions(person), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all( color: isVarios ? AppTokens.primary : AppTokens.border, width: isVarios ? 1.5 : 1, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ CircleAvatar( radius: 22, backgroundColor: avatarColor.withValues(alpha: 0.15), foregroundColor: avatarColor, child: Text( initials, style: const TextStyle(fontWeight: FontWeight.w800), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .titleSmall ?.copyWith( fontWeight: FontWeight.w600, ), ), ), if (isInactive) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: AppTokens.secondary.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(999), ), child: Text( 'Inactivo', style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: AppTokens.secondary, ), ), ), ], ], ), const SizedBox(height: 2), Row( children: [ if (docTypeShort.isNotEmpty) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 1), decoration: BoxDecoration( color: _colorForDocType(docTypeId) .withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), ), child: Text( docTypeShort, style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: _colorForDocType(docTypeId), fontWeight: FontWeight.w700, fontSize: 10, ), ), ), const SizedBox(width: 6), ], Expanded( child: Text( docNumber, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: AppTokens.secondary, ), ), ), ], ), ], ), ), // Acciones rápidas como íconos. if (hasWa) _ActionIcon( icon: Icons.chat_rounded, color: const Color(0xFF25D366), tooltip: 'WhatsApp', onTap: () => _launchContact('https://wa.me/$waNumber'), ), if (hasPhone) _ActionIcon( icon: Icons.phone_rounded, color: AppTokens.primary, tooltip: 'Llamar', onTap: () => _launchContact('tel:$phone'), ), if (hasEmail) _ActionIcon( icon: Icons.mail_outline_rounded, color: AppTokens.secondary, tooltip: 'Correo', onTap: () => _launchContact('mailto:$email'), ), ], ), if (hasAddress) ...[ const SizedBox(height: 6), Padding( padding: const EdgeInsets.only(left: 56), child: Row( children: [ const Icon(Icons.location_on_outlined, size: 13, color: AppTokens.secondary), const SizedBox(width: 4), Expanded( child: Text( address, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: AppTokens.secondary), ), ), ], ), ), ], ], ), ), ), ), ); } Future _showQuickActions(Map person) async { final phone = person['telephone']?.toString() ?? ''; final hasPhone = phone.isNotEmpty && phone != '-'; final rawDigits = phone.replaceAll(RegExp(r'[^0-9]'), ''); final waNumber = rawDigits.length == 9 ? '51$rawDigits' : rawDigits; final email = person['email']?.toString() ?? ''; final hasEmail = email.isNotEmpty && email != '-'; await showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.edit_rounded), title: const Text('Editar'), onTap: () { Navigator.pop(ctx); _showEditor(record: person); }, ), if (hasPhone) ListTile( leading: const Icon(Icons.phone_rounded, color: AppTokens.primary), title: const Text('Llamar'), subtitle: Text(phone), onTap: () { Navigator.pop(ctx); _launchContact('tel:$phone'); }, ), if (hasPhone) ListTile( leading: const Icon(Icons.chat_rounded, color: Color(0xFF25D366)), title: const Text('WhatsApp'), onTap: () { Navigator.pop(ctx); _launchContact('https://wa.me/$waNumber'); }, ), if (hasEmail) ListTile( leading: const Icon(Icons.mail_outline_rounded), title: const Text('Correo'), subtitle: Text(email), onTap: () { Navigator.pop(ctx); _launchContact('mailto:$email'); }, ), ], ), ), ); } String _initialsFor(String name) { final parts = name.trim().split(RegExp(r'\s+')).where((s) => s.isNotEmpty).toList(); if (parts.isEmpty) return '?'; if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); return (parts[0].substring(0, 1) + parts[1].substring(0, 1)) .toUpperCase(); } Color _colorForName(String name) { const palette = [ Color(0xFF00796B), Color(0xFFF97316), Color(0xFF6366F1), Color(0xFFEC4899), Color(0xFF10B981), Color(0xFF8B5CF6), Color(0xFFEF4444), Color(0xFF0EA5E9), ]; final hash = name.codeUnits.fold(0, (acc, c) => acc + c); return palette[hash % palette.length]; } String _docTypeShort(String id) { switch (id) { case '1': return 'DNI'; case '6': return 'RUC'; case '4': return 'CE'; case '7': return 'PAS'; case '0': return 'OTRO'; default: return ''; } } Color _colorForDocType(String id) { switch (id) { case '6': return AppTokens.primary; case '1': return AppTokens.cta; default: return AppTokens.secondary; } } } class _ActionIcon extends StatelessWidget { const _ActionIcon({ required this.icon, required this.color, required this.tooltip, required this.onTap, }); final IconData icon; final Color color; final String tooltip; final VoidCallback onTap; @override Widget build(BuildContext context) { return Tooltip( message: tooltip, child: SizedBox( width: 36, height: 36, child: IconButton( padding: EdgeInsets.zero, onPressed: onTap, icon: Icon(icon, color: color, size: 20), visualDensity: VisualDensity.compact, ), ), ); } }