import 'dart:async'; import 'package:sysfarma_mobile/src/app/widgets/app_loader.dart'; import 'package:sysfarma_mobile/src/app/widgets/app_panel.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/connectivity_service.dart'; import 'package:sysfarma_mobile/src/core/network/mobile_api_response.dart'; import 'package:sysfarma_mobile/src/core/printing/bluetooth_print_service.dart'; import 'package:sysfarma_mobile/src/core/state/unsaved_work_provider.dart'; import 'package:sysfarma_mobile/src/core/storage/offline_pos_store.dart'; import 'package:sysfarma_mobile/src/core/storage/tenant_session_store.dart'; import 'package:sysfarma_mobile/src/core/theme/app_tokens.dart'; import 'package:sysfarma_mobile/src/features/settings/presentation/printer_settings_screen.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'; class SalesScreen extends ConsumerStatefulWidget { const SalesScreen({super.key}); @override ConsumerState createState() => _SalesScreenState(); } class _SalesScreenState extends ConsumerState { static const String _printerMacKey = 'printer_mac'; static const String _printerNameKey = 'printer_name'; late Future<_SalesBundle> _future; final TextEditingController _searchController = TextEditingController(); bool _handleAuthFailure(MobileApiResponse response) { if (response.errors['auth'] == true && mounted) { context.go('/login'); return true; } return false; } final TextEditingController _notesController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final GlobalKey _catalogKey = GlobalKey(); final List<_CartLine> _cart = <_CartLine>[]; // Lineas en proceso de fade+collapse antes de sacarlas de _cart de verdad // (ver _openCartSheet) - asi el ListView tiene tiempo de animar la salida // en vez de que el item desaparezca de golpe en el siguiente build. final Set<_CartLine> _removingCartLines = {}; Map? _selectedCustomer; String? _documentTypeId; int? _seriesId; String? _paymentConditionId; // Estado de pagos estilo POS web: cada método es un tile con su propio monto. final Map _methodAmountControllers = {}; final Map _methodReferences = {}; String? _activeMethodId; bool _paymentsTouched = false; bool _isSubmitting = false; bool _suppressCartSheet = false; bool _isSyncingPending = false; int _pendingSalesCount = 0; Timer? _searchDebounce; StreamSubscription? _connectivitySub; // Customers pagination state List> _allCustomers = []; int _customersPage = 1; bool _customersHasMore = true; bool _isLoadingMoreCustomers = false; // Items search state List> _allItems = []; int _itemsPage = 1; bool _itemsHasMore = true; bool _isLoadingItems = false; String _itemsQuery = ''; @override void initState() { super.initState(); _searchController.addListener(_onSearchChanged); _future = _load(); _connectivitySub = ref .read(connectivityServiceProvider) .onConnectivityChanged .listen((connected) { if (connected) { _syncPendingSales(); } }); } @override void dispose() { _connectivitySub?.cancel(); _searchDebounce?.cancel(); _searchController.dispose(); _notesController.dispose(); _scrollController.dispose(); // El carrito local muere con la pantalla; que el shell deje de creer // que hay una venta en curso. ref.read(hasUnsavedSaleCartProvider.notifier).state = false; super.dispose(); } Future<_SalesBundle> _load() async { final session = await ref.read(tenantSessionStoreProvider).readSession(); final establishmentId = session?.activeEstablishmentId; final offline = ref.read(offlinePosStoreProvider); try { final api = ref.read(apiClientProvider); final results = await Future.wait([ api.documentsTablesByEstablishment(establishmentId: establishmentId), api.items( perPage: 10, page: 1, context: 'sale', establishmentId: establishmentId), api.customers(perPage: 10, page: 1, establishmentId: establishmentId), ]); final tables = results[0] as MobileApiResponse>; final items = results[1] as MobileApiResponse>>; final customers = results[2] as MobileApiResponse>>; // ignore: avoid_print print( '[EnterFarma] sales _load tables.success=${tables.success} items.success=${items.success} customers.success=${customers.success}'); // ignore: avoid_print print( '[EnterFarma] sales _load tables.msg=${tables.message} items.msg=${items.message} customers.msg=${customers.message}'); var itemsList = items.data ?? const >[]; var customersList = customers.data ?? const >[]; var tablesData = tables.data ?? const {}; final hasNetworkFailure = _looksLikeNetworkError(tables.message) || _looksLikeNetworkError(items.message) || _looksLikeNetworkError(customers.message); if (hasNetworkFailure) { final cachedTables = await offline.readTables(); final cachedItems = await offline.readItems(); final cachedCustomers = await offline.readCustomers(); if (!tables.success && cachedTables.isNotEmpty) { tablesData = cachedTables; } if (!items.success && cachedItems.isNotEmpty) { itemsList = cachedItems; } if (!customers.success && cachedCustomers.isNotEmpty) { customersList = cachedCustomers; } } if (tables.success) { await offline.saveTables(tablesData); } if (items.success) { await offline.saveItems(itemsList); } if (customers.success) { await offline.saveCustomers(customersList); } final pending = await offline.readPendingSales(); _pendingSalesCount = pending.length; // Initialize customers pagination _allCustomers = List>.from(customersList); _customersPage = 1; _customersHasMore = customers.meta['has_more'] == true && customersList.isNotEmpty; _isLoadingMoreCustomers = false; _allItems = List>.from(itemsList); _itemsPage = 1; _itemsHasMore = items.meta['has_more'] == true && itemsList.isNotEmpty; _isLoadingItems = false; if (_selectedCustomer == null) { final defaultCustomer = customersList.cast?>().firstWhere( (row) => row?['number']?.toString() == '99999999', orElse: () => null, ); if (defaultCustomer != null) { _selectedCustomer = Map.from(defaultCustomer); } } return _SalesBundle( tables: tablesData, tablesSuccess: tables.success || tablesData.isNotEmpty, tablesMessage: tables.success ? tables.message : (tablesData.isNotEmpty ? 'Cargando datos locales.' : tables.message), items: itemsList, itemsSuccess: items.success || itemsList.isNotEmpty, itemsMessage: items.success ? items.message : (itemsList.isNotEmpty ? 'Catalogo local.' : items.message), customers: customersList, customersSuccess: customers.success || customersList.isNotEmpty, customersMessage: customers.success ? customers.message : (customersList.isNotEmpty ? 'Clientes locales.' : customers.message), ); } catch (e) { final rawError = e.toString(); final isNetworkError = _looksLikeNetworkError(rawError); final cachedTables = await offline.readTables(); final cachedItems = await offline.readItems(); final cachedCustomers = await offline.readCustomers(); final pending = await offline.readPendingSales(); _pendingSalesCount = pending.length; final offlineFallbackMessage = isNetworkError ? 'Sin internet. Para vender offline primero carga Ventas una vez con conexion para guardar catalogo y clientes.' : rawError; return _SalesBundle( tables: cachedTables, tablesSuccess: cachedTables.isNotEmpty, tablesMessage: cachedTables.isNotEmpty ? 'Cargando datos locales.' : offlineFallbackMessage, items: cachedItems, itemsSuccess: cachedItems.isNotEmpty, itemsMessage: cachedItems.isNotEmpty ? 'Catalogo local.' : null, customers: cachedCustomers, customersSuccess: cachedCustomers.isNotEmpty, customersMessage: cachedCustomers.isNotEmpty ? 'Clientes locales.' : null, ); } } Future _refresh() async { final future = _load(); setState(() { _future = future; }); await future; } void _showMessage(String message) { if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), behavior: SnackBarBehavior.fixed, ), ); } String _shortDocTypeLabel(Map docType) { final id = docType['id']?.toString() ?? ''; const shortNames = { '01': 'Factura', '03': 'Boleta', '07': 'Nota crédito', '08': 'Nota débito', '80': 'Nota venta', }; return shortNames[id] ?? (docType['description']?.toString() ?? 'Doc'); } Future _openOptionsSheet(_SalesBundle bundle) async { _initPaymentTilesIfNeeded(bundle); await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetCtx) { return StatefulBuilder( builder: (sheetCtx, setSheetState) { final documentTypeId = _resolvedDocumentTypeId(bundle); final seriesId = _resolvedSeriesId(bundle, documentTypeId); final paymentConditionId = _resolvedPaymentConditionId(bundle); final activeSeries = bundle.seriesFor(documentTypeId); final totals = _calculateTotals(); final covered = _paymentsCovered; final pending = totals.total - covered; return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 16, 20, 24 + MediaQuery.of(sheetCtx).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.tune_rounded, color: AppTokens.primary), const SizedBox(width: 10), Text( 'Opciones del comprobante', style: Theme.of(context).textTheme.titleMedium, ), ], ), const SizedBox(height: 18), Wrap( spacing: 8, runSpacing: 8, children: [ for (final docType in bundle.documentTypes) ChoiceChip( label: Text(_shortDocTypeLabel(docType)), selected: documentTypeId == docType['id']?.toString(), onSelected: (_) { _setDocumentType( docType['id']?.toString(), bundle); setSheetState(() {}); }, ), ], ), const SizedBox(height: 14), DropdownButtonFormField( initialValue: seriesId, isExpanded: true, decoration: const InputDecoration( labelText: 'Serie', prefixIcon: Icon(Icons.confirmation_number_rounded), ), items: [ for (final series in activeSeries) DropdownMenuItem( value: _asInt(series['id']), child: Text(series['number']?.toString() ?? 'Serie'), ), ], onChanged: (value) { setState(() => _seriesId = value); setSheetState(() {}); }, ), const SizedBox(height: 14), DropdownButtonFormField( initialValue: paymentConditionId, isExpanded: true, decoration: const InputDecoration( labelText: 'Condición de pago', prefixIcon: Icon(Icons.account_balance_wallet_rounded), ), items: [ for (final condition in bundle.paymentConditions) DropdownMenuItem( value: condition['id']?.toString(), child: Text(condition['name']?.toString() ?? condition['description']?.toString() ?? 'Condicion'), ), ], onChanged: (value) { setState(() => _paymentConditionId = value); setSheetState(() {}); }, ), if (paymentConditionId == '01') ...[ const SizedBox(height: 18), Row( children: [ const Icon(Icons.payments_rounded, size: 18, color: AppTokens.secondary), const SizedBox(width: 6), Text( 'Métodos de pago', style: Theme.of(context).textTheme.titleSmall, ), const Spacer(), TextButton.icon( onPressed: () { setState(() { _paymentsTouched = false; _methodReferences.clear(); _initPaymentTilesIfNeeded(bundle); }); setSheetState(() {}); }, icon: const Icon(Icons.refresh_rounded, size: 16), label: const Text('Resetear'), ), ], ), const SizedBox(height: 8), for (final method in bundle.paymentMethodTypes.where((m) { final isCredit = m['is_credit']; final isCreditFlag = isCredit == true || isCredit == 1 || isCredit?.toString() == '1' || isCredit?.toString() == 'true'; return !isCreditFlag; })) Padding( padding: const EdgeInsets.only(bottom: 8), child: _PaymentMethodTile( method: method, controller: _ctrlForMethod(method['id']?.toString() ?? ''), isActive: _amountForMethod( method['id']?.toString() ?? '') > 0, isSelected: _activeMethodId == method['id']?.toString(), reference: _methodReferences[ method['id']?.toString() ?? ''], isCash: (method['id']?.toString() ?? '') == _defaultPaymentMethodTypeId(bundle), onTap: () { final id = method['id']?.toString() ?? ''; setState(() { _paymentsTouched = true; _onPaymentTileTapped(id, totals.total); }); setSheetState(() {}); }, onAmountChanged: (_) { final id = method['id']?.toString() ?? ''; setState(() { _paymentsTouched = true; _activeMethodId = id; }); setSheetState(() {}); }, onReferenceTap: () async { final id = method['id']?.toString() ?? ''; final value = await _askReference( method['description']?.toString() ?? 'Pago', _methodReferences[id] ?? '', ); if (value != null) { setState(() { _methodReferences[id] = value; _paymentsTouched = true; }); setSheetState(() {}); } }, ), ), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 12), decoration: BoxDecoration( color: pending.abs() < 0.01 ? AppTokens.success.withValues(alpha: 0.10) : pending > 0 ? AppTokens.warning.withValues(alpha: 0.10) : AppTokens.primary.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: Row( children: [ Icon( pending.abs() < 0.01 ? Icons.check_circle_rounded : pending > 0 ? Icons.info_outline_rounded : Icons.attach_money_rounded, size: 18, color: pending.abs() < 0.01 ? AppTokens.success : pending > 0 ? AppTokens.warning : AppTokens.primary, ), const SizedBox(width: 8), Expanded( child: Text( pending.abs() < 0.01 ? 'Total: S/ ${totals.total.toStringAsFixed(2)}' : pending > 0 ? 'Faltante: S/ ${pending.toStringAsFixed(2)}' : 'Vuelto: S/ ${(-pending).toStringAsFixed(2)}', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(fontWeight: FontWeight.w600), ), ), Text( 'Pagado: S/ ${covered.toStringAsFixed(2)}', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ], const SizedBox(height: 14), TextField( controller: _notesController, minLines: 2, maxLines: 4, decoration: const InputDecoration( labelText: 'Observaciones', prefixIcon: Icon(Icons.sticky_note_2_rounded), ), ), const SizedBox(height: 18), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () => Navigator.of(sheetCtx).pop(), icon: const Icon(Icons.check_rounded), label: const Text('Cerrar opciones'), ), ), ], ), ), ); }, ); }, ); if (mounted) setState(() {}); } Future _askReference(String methodName, String current) async { final controller = TextEditingController(text: current); return showDialog( context: context, builder: (ctx) => AlertDialog( title: Text('Referencia · $methodName'), content: TextField( controller: controller, autofocus: true, decoration: const InputDecoration( hintText: 'Ej. operación, voucher, últimos 4', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(ctx, controller.text.trim()), child: const Text('Guardar'), ), ], ), ); } Future _scanSalesBarcode() async { final value = await context.push('/scanner'); if (value == null || value.isEmpty || !mounted) return; _searchController.text = value; setState(() {}); } Future _ensureCashOpen() async { final api = ref.read(apiClientProvider); final response = await api.cashStatus(); if (!mounted) { return false; } if (!response.success) { if (_looksLikeNetworkError(response.message)) { _showMessage( 'Sin internet. Se continuara en modo offline y la venta se guardara en cola.', ); return true; } _showMessage( response.message ?? 'No se pudo validar el estado de la caja.'); return false; } final data = response.data ?? const {}; final hasOpenCash = data['has_open_cash'] == true || data['cash'] != null; if (hasOpenCash) { return true; } await _openCashSheetForSales(); return false; } Future _openCashSheetForSales() async { final beginningBalanceController = TextEditingController(text: '0'); final formKey = GlobalKey(); String? errorText; var isSubmitting = false; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { return StatefulBuilder( builder: (context, setSheetState) { Future submit() async { if (!(formKey.currentState?.validate() ?? false) || isSubmitting) { return; } setSheetState(() { isSubmitting = true; errorText = null; }); final api = ref.read(apiClientProvider); final response = await api.openCash( beginningBalance: double.tryParse(beginningBalanceController.text.trim()) ?? 0, ); if (!response.success) { setSheetState(() { isSubmitting = false; errorText = response.message ?? 'No se pudo aperturar la caja.'; }); return; } if (!context.mounted) return; if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } _showMessage(response.message ?? 'Caja aperturada.'); } return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 8, 20, 24 + MediaQuery.of(context).viewInsets.bottom, ), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ CircleAvatar( backgroundColor: AppTokens.success.withValues(alpha: 0.12), foregroundColor: AppTokens.success, child: const Icon(Icons.lock_open_rounded), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Aperturar caja', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 4), Text( 'Abre la caja con un saldo inicial para empezar a emitir.', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith( color: AppTokens.secondary, ), ), ], ), ), ], ), const SizedBox(height: 18), TextFormField( controller: beginningBalanceController, keyboardType: const TextInputType.numberWithOptions( decimal: true), decoration: const InputDecoration( labelText: 'Saldo inicial', prefixIcon: Icon(Icons.payments_rounded), ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'Ingresa el saldo inicial.'; } if (double.tryParse(value.trim()) == null) { return 'Ingresa un monto valido.'; } return null; }, ), if (errorText != null) ...[ const SizedBox(height: 12), Text( errorText!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.danger, ), ), ], const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton( onPressed: isSubmitting ? null : submit, child: Text(isSubmitting ? 'Procesando...' : 'Aperturar caja'), ), ), ], ), ), ), ); }, ); }, ); } String? _resolvedDocumentTypeId(_SalesBundle bundle) { if (_documentTypeId != null) { return _documentTypeId; } if (bundle.documentTypes.isEmpty) { return null; } final defaultType = bundle.documentTypes.cast?>().firstWhere( (row) => row?['id']?.toString() == '03', orElse: () => null, ); return defaultType?['id']?.toString() ?? bundle.documentTypes.first['id']?.toString(); } int? _resolvedSeriesId(_SalesBundle bundle, String? documentTypeId) { if (_seriesId != null) { final exists = bundle .seriesFor(documentTypeId) .any((row) => _asInt(row['id']) == _seriesId); if (exists) { return _seriesId; } } final currentSeries = bundle.seriesFor(documentTypeId); if (currentSeries.isEmpty) { return null; } final defaultSeries = currentSeries.cast?>().firstWhere( (row) => row?['is_default'] == true, orElse: () => null, ); return _asInt(defaultSeries?['id']) ?? _asInt(currentSeries.first['id']); } String _resolvedPaymentConditionId(_SalesBundle bundle) { if (_paymentConditionId != null) { return _paymentConditionId!; } final defaultRow = bundle.paymentConditions.cast?>().firstWhere( (row) => row?['id']?.toString() == '01', orElse: () => null, ); return defaultRow?['id']?.toString() ?? '01'; } String _defaultPaymentMethodTypeId(_SalesBundle bundle) { return bundle.paymentMethodTypes.isNotEmpty ? bundle.paymentMethodTypes.first['id']?.toString() ?? '01' : '01'; } String? _defaultPaymentDestinationId(_SalesBundle bundle) { if (bundle.paymentDestinations.isEmpty) return null; final cash = bundle.paymentDestinations.cast?>().firstWhere( (row) => row?['id']?.toString() == 'cash', orElse: () => null, ); return cash?['id']?.toString() ?? bundle.paymentDestinations.first['id']?.toString(); } TextEditingController _ctrlForMethod(String id) { return _methodAmountControllers.putIfAbsent( id, () => TextEditingController(text: '0.00'), ); } double _amountForMethod(String id) { final txt = _methodAmountControllers[id]?.text.trim() ?? ''; return double.tryParse(txt) ?? 0; } void _setAmountForMethod(String id, double amount) { final ctrl = _ctrlForMethod(id); ctrl.text = amount > 0 ? amount.toStringAsFixed(2) : '0.00'; ctrl.selection = TextSelection.fromPosition( TextPosition(offset: ctrl.text.length), ); } void _initPaymentTilesIfNeeded(_SalesBundle bundle) { final cashMethods = bundle.paymentMethodTypes.where((m) { // Excluye crédito (factura a 30 días, etc.) en condición contado. final isCredit = m['is_credit']; final isCreditFlag = isCredit == true || isCredit == 1 || isCredit?.toString() == '1' || isCredit?.toString() == 'true'; if (isCreditFlag) return false; // Respeta el active_pos del POS web. final activePos = m['active_pos']; if (activePos == null) return true; if (activePos is bool) return activePos; return activePos.toString() == '1' || activePos.toString() == 'true'; }).toList(); if (cashMethods.isEmpty) return; for (final m in cashMethods) { final id = m['id']?.toString() ?? ''; if (id.isEmpty) continue; _ctrlForMethod(id); } if (!_paymentsTouched) { final totals = _calculateTotals(); final cashId = cashMethods.first['id']?.toString() ?? '01'; for (final m in cashMethods) { final id = m['id']?.toString() ?? ''; if (id.isNotEmpty) _setAmountForMethod(id, 0); } _setAmountForMethod(cashId, totals.total); _activeMethodId = cashId; _methodReferences.clear(); } } double get _paymentsCovered { var sum = 0.0; for (final entry in _methodAmountControllers.entries) { sum += _amountForMethod(entry.key); } return sum; } void _onPaymentTileTapped(String id, double totalDocument) { final tappedAmount = _amountForMethod(id); final activeBefore = _activePayments(); // Tap en un tile activo → toggle off (quita su monto). if (tappedAmount > 0 && _activeMethodId == id) { _setAmountForMethod(id, 0); // Si quedó sin métodos activos, vuelve a marcar éste como seleccionado vacío. final remaining = _activePayments(); _activeMethodId = remaining.isNotEmpty ? remaining.first.key : id; return; } // Caso 1: hay un método activo que ya cubre el total → transferir todo al tapeado. if (activeBefore.length == 1 && (activeBefore.first.value - totalDocument).abs() < 0.01 && totalDocument > 0) { _setAmountForMethod(activeBefore.first.key, 0); _setAmountForMethod(id, totalDocument); _activeMethodId = id; return; } // Caso 2: pago mixto en construcción → asigna lo que falta al nuevo método. final covered = _paymentsCovered; final remaining = totalDocument - covered; if (remaining > 0) { _setAmountForMethod(id, _amountForMethod(id) + remaining); } else if (tappedAmount <= 0) { // Nada falta; activa el método con el total para reemplazo manual. _setAmountForMethod(id, totalDocument); } _activeMethodId = id; } List> _activePayments() { final list = >[]; for (final entry in _methodAmountControllers.entries) { final amount = _amountForMethod(entry.key); if (amount > 0) list.add(MapEntry(entry.key, amount)); } return list; } void _onSearchChanged() { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 350), () { _searchItems(query: _searchController.text.trim()); }); } Future _searchItems({required String query}) async { final normalized = query.trim(); if (_itemsQuery == normalized && _allItems.isNotEmpty) { return; } setState(() { _itemsQuery = normalized; _isLoadingItems = true; }); final response = await ref.read(apiClientProvider).items( perPage: 40, page: 1, context: 'sale', search: normalized.isEmpty ? null : normalized, ); if (!mounted) { return; } setState(() { _allItems = response.data ?? const >[]; _itemsPage = 1; _itemsHasMore = response.meta['has_more'] == true && _allItems.isNotEmpty; _isLoadingItems = false; }); } Future _loadMoreItems() async { if (_isLoadingItems || !_itemsHasMore) { return; } setState(() => _isLoadingItems = true); final response = await ref.read(apiClientProvider).items( perPage: 40, page: _itemsPage + 1, context: 'sale', search: _itemsQuery.isEmpty ? null : _itemsQuery, ); if (!mounted) { return; } final newItems = response.data ?? const >[]; setState(() { _allItems.addAll(newItems); _itemsPage += 1; _itemsHasMore = response.meta['has_more'] == true && newItems.isNotEmpty; _isLoadingItems = false; }); } List> _filteredItems(_SalesBundle bundle) { if (_allItems.isNotEmpty || _itemsQuery.isNotEmpty || _isLoadingItems) { return _allItems; } return bundle.items; } void _setDocumentType(String? value, _SalesBundle bundle) { setState(() { _documentTypeId = value; _seriesId = _resolvedSeriesId(bundle, value); }); } Future _addItem(Map item) async { final itemId = _asInt(item['id']); if (itemId == null) { return; } final isService = _isService(item); final stock = _itemStock(item); final unitTypes = _itemUnitTypes(item); final presentation = await _pickPresentation(item); if (unitTypes.length > 1 && presentation == null) { return; } final selectedUnitTypeId = presentation?.unitTypeId ?? item['unit_type_id']?.toString(); final quantityFactor = presentation?.quantityUnit ?? 1; final unitPrice = presentation?.unitPrice ?? _saleUnitPriceForCart(item); if (!isService && stock <= 0) { _showMessage('El producto no tiene stock disponible.'); return; } if (!isService && quantityFactor > stock) { _showMessage('La cantidad supera el stock disponible.'); return; } final existingIndex = _cart.indexWhere( (line) => line.itemId == itemId && line.presentationUnitTypeId == selectedUnitTypeId, ); setState(() { if (existingIndex >= 0) { final nextQuantity = _cart[existingIndex].quantity + 1; if (!isService && nextQuantity * quantityFactor > stock) { _showMessage('La cantidad supera el stock disponible.'); return; } _cart[existingIndex].quantity += 1; } else { _cart.add(_CartLine( item: Map.from(item), quantity: 1, unitPrice: unitPrice, presentationUnitTypeId: selectedUnitTypeId, presentationDescription: presentation?.description, quantityFactor: quantityFactor, )); } }); } void _changeQuantity(_CartLine line, double delta) { final nextQuantity = line.quantity + delta; if (nextQuantity <= 0) { setState(() => _cart.remove(line)); return; } final isService = _isService(line.item); final stock = _itemStock(line.item); if (!isService && delta > 0 && nextQuantity * line.quantityFactor > stock) { _showMessage('La cantidad supera el stock disponible.'); return; } setState(() => line.quantity = nextQuantity); } double _itemStock(Map item) { if (_isService(item)) { return double.infinity; } return _asDouble(item['stock']); } bool _isService(Map item) { final unitType = item['unit_type_id']?.toString().toUpperCase(); if (unitType == 'ZZ') { return true; } final itemType = item['item_type_id']?.toString(); return itemType == '02'; } Future _loadMoreCustomers( void Function(void Function()) setSheetState) async { if (_isLoadingMoreCustomers || !_customersHasMore) { return; } setSheetState(() => _isLoadingMoreCustomers = true); try { final response = await ref.read(apiClientProvider).customers( perPage: 60, page: _customersPage + 1, ); if (response.success) { final newItems = response.data ?? const []; final moreAvailable = response.meta['has_more'] == true; setSheetState(() { _allCustomers.addAll(newItems); _customersPage += 1; _customersHasMore = moreAvailable && newItems.isNotEmpty; _isLoadingMoreCustomers = false; }); } else { setSheetState(() => _isLoadingMoreCustomers = false); } } catch (_) { setSheetState(() => _isLoadingMoreCustomers = false); } } Future _selectCustomer(_SalesBundle bundle) async { final customerScrollController = ScrollController(); final picked = await showModalBottomSheet>( context: context, isScrollControlled: true, builder: (context) { var query = ''; return StatefulBuilder( builder: (context, setSheetState) { // Attach scroll listener for customer pagination customerScrollController.removeListener(() {}); void onCustomerScroll() { if (customerScrollController.hasClients && customerScrollController.position.pixels >= customerScrollController.position.maxScrollExtent - 200) { _loadMoreCustomers(setSheetState); } } customerScrollController.addListener(onCustomerScroll); final source = _allCustomers.isNotEmpty ? _allCustomers : bundle.customers; final filtered = query.trim().isEmpty ? source : source.where((customer) { final text = [ customer['description'], customer['name'], customer['number'], ].whereType().join(' ').toLowerCase(); return text.contains(query.trim().toLowerCase()); }).toList(); return SafeArea( child: Padding( padding: EdgeInsets.fromLTRB( 20, 8, 20, 24 + MediaQuery.of(context).viewInsets.bottom, ), child: SizedBox( height: MediaQuery.of(context).size.height * 0.72, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Seleccionar cliente', style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 8), Text( 'Elige el receptor antes de emitir el comprobante o la nota.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.secondary, ), ), const SizedBox(height: 16), TextField( decoration: const InputDecoration( labelText: 'Buscar cliente', prefixIcon: Icon(Icons.search_rounded), ), onChanged: (value) => setSheetState(() => query = value), ), const SizedBox(height: 16), Expanded( child: filtered.isEmpty ? const EmptyStatePanel( icon: Icons.person_off_rounded, title: 'No hay coincidencias', subtitle: 'Prueba con documento, nombre o razon social.', ) : ListView.separated( controller: customerScrollController, itemCount: filtered.length + (_isLoadingMoreCustomers ? 1 : 0), separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, index) { if (index >= filtered.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2), ), ), ); } final customer = filtered[index]; final selected = _asInt(customer['id']) == _asInt(_selectedCustomer?['id']); return InkWell( borderRadius: BorderRadius.circular( AppTokens.radiusMedium), onTap: () => Navigator.of(context).pop(customer), child: Ink( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: selected ? AppTokens.primary .withValues(alpha: 0.08) : AppTokens.surface, borderRadius: BorderRadius.circular( AppTokens.radiusMedium), border: Border.all( color: selected ? AppTokens.primary : AppTokens.border, ), ), child: Row( children: [ CircleAvatar( backgroundColor: AppTokens.primary .withValues(alpha: 0.12), foregroundColor: AppTokens.primary, child: const Icon( Icons.person_rounded), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( customer['name'] ?.toString() ?? 'Cliente', style: Theme.of(context) .textTheme .titleMedium, ), const SizedBox(height: 4), Text( customer['description'] ?.toString() ?? customer['number'] ?.toString() ?? 'Sin documento', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith( color: AppTokens.secondary, ), ), ], ), ), if (selected) const Icon( Icons.check_circle_rounded, color: AppTokens.primary, ), ], ), ), ); }, ), ), ], ), ), ), ); }, ); }, ); customerScrollController.dispose(); if (picked == null) { return; } setState(() => _selectedCustomer = Map.from(picked)); } Future _showDocumentDetail( Map record, _SalesBundle bundle, ) async { Map detail = Map.from(record); final id = _asInt(record['id']); final model = record['model']?.toString() ?? 'document'; if (id != null) { final response = await ref .read(apiClientProvider) .documentDetail(id: id, model: model); if (response.success && response.data != null) { detail = Map.from(response.data!); } } final pdfUrl = detail['pdf_url']?.toString(); final ticketUrl = detail['ticket_url']?.toString(); final normalizedPdfUrl = (pdfUrl ?? '').trim(); final normalizedTicketUrl = (ticketUrl ?? '').trim(); final compactPdfUrl = normalizedTicketUrl.isNotEmpty ? normalizedTicketUrl : normalizedPdfUrl; final hasCompactPdf = compactPdfUrl.isNotEmpty; final hasShareableLink = normalizedTicketUrl.isNotEmpty || normalizedPdfUrl.isNotEmpty; final numberFull = detail['number_full']?.toString() ?? 'Comprobante'; final customer = detail['customer']?['description']?.toString() ?? ''; final currency = detail['currency_type_id']?.toString() ?? 'PEN'; final total = _formatNumber(_asDouble(detail['total'])); final dateOfIssue = detail['date_of_issue']?.toString() ?? '-'; if (!mounted) { return; } await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { void closeSheet() { Navigator.of(context).pop(); } void suppressCartSheet() { _suppressCartSheet = true; Future.delayed(const Duration(milliseconds: 500), () { if (!mounted) { return; } setState(() => _suppressCartSheet = false); }); } void scrollToCatalog() { final target = _catalogKey.currentContext; if (target == null) { return; } Scrollable.ensureVisible( target, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppTokens.success.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppTokens.radiusMedium), border: Border.all( color: AppTokens.success.withValues(alpha: 0.28), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ TweenAnimationBuilder( tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 500), curve: Curves.elasticOut, builder: (context, value, child) => Transform.scale( scale: value, child: child, ), child: const Icon(Icons.check_circle_rounded, color: AppTokens.success, size: 28), ), const SizedBox(width: 8), Text( 'Venta exitosa', style: Theme.of(context).textTheme.titleLarge, ), ], ), const SizedBox(height: 8), Text( numberFull, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), ), if (customer.isNotEmpty) ...[ const SizedBox(height: 4), Text( customer, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.secondary, ), ), ], ], ), ), const SizedBox(height: 16), AppPanel( title: 'Resumen', subtitle: detail['document_type_description']?.toString() ?? 'Comprobante', child: Column( children: [ _InfoRow(label: 'Fecha', value: dateOfIssue), const SizedBox(height: 10), _InfoRow( label: 'Total', value: '$currency $total', emphasize: true, ), ], ), ), const SizedBox(height: 16), AppPanel( title: 'Acciones', subtitle: null, child: Row( children: [ Expanded( child: Tooltip( message: 'Imprimir', child: FilledButton.tonal( onPressed: id == null ? null : () => _printTicketDirect(id, model), child: const Icon(Icons.print_rounded), ), ), ), const SizedBox(width: 8), Expanded( child: Tooltip( message: 'PDF', child: FilledButton.tonal( onPressed: hasCompactPdf ? () => _openExternal(compactPdfUrl) : null, child: const Icon(Icons.picture_as_pdf_rounded), ), ), ), const SizedBox(width: 8), Expanded( child: FilledButton.tonal( onPressed: hasShareableLink ? () => _shareWhatsApp( detail: detail, pdfUrl: pdfUrl, ticketUrl: ticketUrl, ) : null, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 10), backgroundColor: const Color(0xFF25D366).withValues(alpha: 0.14), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Image.asset( 'assets/icon/whatsapp_logo.png', width: 18, height: 18, errorBuilder: (_, __, ___) => const Icon( Icons.chat_rounded, size: 18, color: Color(0xFF25D366), ), ), ], ), ), ), ], ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: OutlinedButton( onPressed: () { suppressCartSheet(); closeSheet(); if (!mounted) { return; } context.go('/home'); }, child: const Text('Volver'), ), ), const SizedBox(width: 12), Expanded( child: FilledButton( onPressed: () { suppressCartSheet(); closeSheet(); if (!mounted) { return; } Future.delayed( const Duration(milliseconds: 50), () { if (!mounted) { return; } scrollToCatalog(); }, ); }, child: const Text('Nueva venta'), ), ), ], ), ], ), ), ); }, ); } Future _openExternal(String? url) async { final value = url?.trim() ?? ''; if (value.isEmpty) { _showMessage('No hay enlace disponible para este documento.'); return; } final ok = await launchUrlString( value, mode: LaunchMode.externalApplication, ); if (!ok && mounted) { _showMessage('No se pudo abrir el enlace.'); } } Future _printTicketDirect(int documentId, String model) async { final messenger = ScaffoldMessenger.of(context); final printerReady = await _ensurePrinterConfiguredAndConnected(); if (!printerReady) { return; } messenger.showSnackBar( const SnackBar( content: Text('Preparando impresion...'), duration: Duration(seconds: 1), ), ); final api = ref.read(apiClientProvider); final response = await api .documentPrintPayload(id: documentId, model: model) .timeout(const Duration(seconds: 10)); if (!response.success || response.data == null) { if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text(response.message ?? 'No se pudo preparar la impresion.'), backgroundColor: AppTokens.danger, ), ); return; } try { await BluetoothPrintService() .printTicket(response.data!) .timeout(const Duration(seconds: 15)); if (!mounted) return; messenger.showSnackBar( const SnackBar(content: Text('Ticket enviado a la impresora.')), ); } on StateError { if (!mounted) return; final goSetup = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Impresora no conectada'), content: const Text( 'No hay una impresora Bluetooth conectada. Deseas configurarla ahora?', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Configurar'), ), ], ), ); if (!mounted) return; if (goSetup == true) { await showPrinterSettings(context, ref); } } on TimeoutException { if (!mounted) return; messenger.showSnackBar( const SnackBar( content: Text('Tiempo de espera agotado al imprimir.'), backgroundColor: AppTokens.danger, ), ); } catch (e) { if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text('Error al imprimir: $e'), backgroundColor: AppTokens.danger, ), ); } } Future _ensurePrinterConfiguredAndConnected() async { final storage = ref.read(secureStorageProvider); final savedMac = await storage.read(key: _printerMacKey); final savedName = await storage.read(key: _printerNameKey); if (!mounted) { return false; } if (savedMac == null || savedMac.trim().isEmpty) { final goSetup = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Impresora no configurada'), content: const Text( 'No tienes una impresora configurada. Conecta y configura una impresora Bluetooth para imprimir.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Configurar'), ), ], ), ) ?? false; if (goSetup) { if (!mounted) return false; await showPrinterSettings(context, ref); } return false; } final printService = ref.read(bluetoothPrintServiceProvider); final connected = await printService.refreshConnectionStatus(); if (connected) { return true; } if (!mounted) { return false; } final displayName = (savedName == null || savedName.trim().isEmpty) ? savedMac : savedName; final goSetup = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Impresora desconectada'), content: Text( 'La impresora guardada ($displayName) no esta conectada. Enciendela y vuelve a vincular si es necesario.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Configurar'), ), ], ), ) ?? false; if (goSetup) { if (!mounted) return false; await showPrinterSettings(context, ref); } return false; } Future _shareWhatsApp({ required Map detail, String? pdfUrl, String? ticketUrl, }) async { final link = (ticketUrl ?? pdfUrl ?? '').trim(); if (link.isEmpty) { _showMessage('No hay enlace disponible para compartir.'); return; } final number = detail['number_full']?.toString() ?? 'Documento'; final customer = detail['customer']?['description']?.toString() ?? ''; final total = _formatNumber(_asDouble(detail['total'])); final currency = detail['currency_type_id']?.toString() ?? 'PEN'; final text = [ number, if (customer.isNotEmpty) customer, 'Total $currency $total', link, ].join('\n'); final url = 'https://wa.me/?text=${Uri.encodeComponent(text)}'; final ok = await launchUrlString( url, mode: LaunchMode.externalApplication, ); if (!ok && mounted) { _showMessage('No se pudo abrir WhatsApp.'); } } Future _submitSale( _SalesBundle bundle, { VoidCallback? onSubmittingChanged, }) async { final documentTypeId = _resolvedDocumentTypeId(bundle); final seriesId = _resolvedSeriesId(bundle, documentTypeId); if (_selectedCustomer == null) { _showMessage('Selecciona un cliente antes de emitir.'); return; } if (documentTypeId == null || seriesId == null) { _showMessage('Selecciona un tipo de documento y una serie valida.'); return; } if (_cart.isEmpty) { _showMessage('Agrega al menos un producto al carrito.'); return; } final customerDocumentType = _selectedCustomer?['identity_document_type_id']?.toString(); final totals = _calculateTotals(); if (documentTypeId == '01' && customerDocumentType != '6') { _showMessage('La factura requiere un cliente con RUC.'); return; } if (documentTypeId == '03' && totals.total > 700 && !const ['1', '4', '6', '7'].contains(customerDocumentType)) { _showMessage( 'La boleta mayor a 700 requiere un documento valido en el cliente.'); return; } // _isSubmitting arranca ANTES de _ensureCashOpen() (que tambien llama a // la red) para que el boton muestre "Emitiendo..." durante toda la // espera, no solo durante createDocument. onSubmittingChanged fuerza el // refresco del bottom sheet (sheetSetState), que vive fuera del arbol de // _SalesScreenState y no se entera del setState de aca solo. setState(() => _isSubmitting = true); onSubmittingChanged?.call(); final cashReady = await _ensureCashOpen(); if (!cashReady) { if (mounted) setState(() => _isSubmitting = false); onSubmittingChanged?.call(); return; } final idempotencyKey = _nextIdempotencyKey(); final payload = _buildPayload(bundle, documentTypeId: documentTypeId, seriesId: seriesId); final response = await ref.read(apiClientProvider).createDocument( payload: payload, idempotencyKey: idempotencyKey, ); if (!mounted) { return; } setState(() => _isSubmitting = false); onSubmittingChanged?.call(); if (_handleAuthFailure(response)) { return; } if (!response.success) { final isNetworkError = _looksLikeNetworkError(response.message); if (isNetworkError) { await ref.read(offlinePosStoreProvider).enqueuePendingSale({ 'idempotency_key': idempotencyKey, 'payload': payload, 'created_at': DateTime.now().toIso8601String(), 'attempts': 0, 'last_error': null, 'status': 'pending', }); final pending = await ref.read(offlinePosStoreProvider).readPendingSales(); setState(() { _pendingSalesCount = pending.length; _cart.clear(); _notesController.clear(); _paymentsTouched = false; _methodReferences.clear(); }); _showMessage( 'Sin internet: venta en cola (${pending.length}). El comprobante fiscal se emitira al sincronizar.', ); return; } _showMessage(response.message ?? 'No se pudo emitir el documento.'); return; } final record = Map.from( (response.data ?? const {})['record'] as Map? ?? const {}, ); setState(() { _cart.clear(); _notesController.clear(); _paymentsTouched = false; _methodReferences.clear(); }); _showMessage(response.message ?? 'Documento emitido.'); await _showDocumentDetail(record, bundle); } Future _syncPendingSales() async { if (_isSyncingPending) return; final online = await ref.read(connectivityServiceProvider).isConnected; if (!online) return; setState(() => _isSyncingPending = true); final store = ref.read(offlinePosStoreProvider); final api = ref.read(apiClientProvider); final pending = await store.readPendingSales(); final remaining = >[]; for (final row in pending) { final payload = Map.from( row['payload'] as Map? ?? const {}, ); final key = row['idempotency_key']?.toString() ?? _nextIdempotencyKey(); final attempts = (row['attempts'] as num?)?.toInt() ?? 0; final response = await api.createDocument( payload: payload, idempotencyKey: key, ); if (!response.success) { remaining.add({ ...row, 'attempts': attempts + 1, 'status': 'failed', 'last_error': response.message, }); } } await store.replacePendingSales(remaining); if (!mounted) return; setState(() { _pendingSalesCount = remaining.length; _isSyncingPending = false; }); if (pending.isNotEmpty) { _showMessage( remaining.isEmpty ? 'Sincronizacion completada.' : 'Sincronizacion parcial. Pendientes: ${remaining.length}', ); } } bool _looksLikeNetworkError(String? message) { final value = (message ?? '').toLowerCase(); return value.contains('socket') || value.contains('timeout') || value.contains('network') || value.contains('connection') || value.contains('conect'); } String _nextIdempotencyKey() { final now = DateTime.now().microsecondsSinceEpoch; final rand = (now % 1000000).toString().padLeft(6, '0'); return 'mob-$now-$rand'; } Map _buildPayload( _SalesBundle bundle, { required String documentTypeId, required int seriesId, }) { final now = DateTime.now(); final dateOfIssue = _formatDate(now); final timeOfIssue = _formatTime(now); final paymentConditionId = _resolvedPaymentConditionId(bundle); final totals = _calculateTotals(); final currencyTypeId = _cart.isEmpty ? 'PEN' : (_cart.first.item['currency_type_id']?.toString() ?? 'PEN'); final defaultDestination = _defaultPaymentDestinationId(bundle); final active = _activePayments(); final payments = paymentConditionId == '01' ? active .map((entry) => { 'id': null, 'date_of_payment': dateOfIssue, 'payment_method_type_id': entry.key, 'payment_destination_id': defaultDestination, 'reference': (_methodReferences[entry.key] ?? '').isEmpty ? null : _methodReferences[entry.key], 'payment': entry.value, }) .toList() : const >[]; final firstMethodId = active.isNotEmpty ? active.first.key : _defaultPaymentMethodTypeId(bundle); final fees = paymentConditionId == '02' ? [ { 'id': null, 'date': dateOfIssue, 'currency_type_id': currencyTypeId, 'amount': totals.total, }, ] : const >[]; return { 'mobile_compose': true, 'model': documentTypeId == '80' ? 'sale_note' : 'document', 'document_type_id': documentTypeId, 'series_id': seriesId, 'customer_id': _selectedCustomer?['id'], 'currency_type_id': currencyTypeId, 'exchange_rate_sale': 1, 'date_of_issue': dateOfIssue, 'time_of_issue': timeOfIssue, 'date_of_due': dateOfIssue, 'payment_condition_id': paymentConditionId, 'payment_method_type_id': firstMethodId, 'operation_type_id': '0101', 'additional_information': _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), 'total_prepayment': 0, 'total_discount': 0, 'total_charge': 0, 'total_exportation': 0, 'total_free': 0, 'total_taxed': totals.taxed, 'total_unaffected': totals.unaffected, 'total_exonerated': totals.exonerated, 'total_igv': totals.igv, 'total_igv_free': 0, 'total_base_isc': 0, 'total_isc': 0, 'total_base_other_taxes': 0, 'total_other_taxes': 0, 'total_plastic_bag_taxes': 0, 'total_taxes': totals.igv, 'total_value': totals.value, 'subtotal': totals.total, 'total': totals.total, 'items': _cart.map(_buildLinePayload).toList(), 'payments': payments, 'fee': fees, 'charges': const >[], 'discounts': const >[], 'guides': const >[], 'total_pending_payment': paymentConditionId == '02' ? totals.total : 0, }; } Map _buildLinePayload(_CartLine line) { final row = _calculateLine(line); final item = line.item; final unitTypeId = line.presentationUnitTypeId ?? item['unit_type_id']; return { 'item_id': line.itemId, 'item': { 'id': line.itemId, 'description': item['description'], 'internal_id': item['internal_id'], 'unit_type_id': unitTypeId, 'sale_unit_price': line.unitPrice, 'unit_price': line.unitPrice, 'has_igv': item['has_igv'] == true, 'purchase_unit_price': 0, 'is_set': item['is_set'] == true, 'lots': const >[], }, 'quantity': line.quantity, 'unit_value': row.unitValue, 'price_type_id': '01', 'unit_price': line.unitPrice, 'affectation_igv_type_id': row.affectationIgvTypeId, 'total_base_igv': row.totalBaseIgv, 'percentage_igv': row.percentageIgv, 'total_igv': row.totalIgv, 'system_isc_type_id': null, 'total_base_isc': 0, 'percentage_isc': 0, 'total_isc': 0, 'total_base_other_taxes': 0, 'percentage_other_taxes': 0, 'total_other_taxes': 0, 'total_plastic_bag_taxes': 0, 'total_taxes': row.totalTaxes, 'total_value': row.totalValue, 'total_charge': 0, 'total_discount': 0, 'total': row.total, 'attributes': const >[], 'charges': const >[], 'discounts': const >[], 'warehouse_id': null, 'additional_information': null, 'quantity_factor': line.quantityFactor, 'presentation_description': line.presentationDescription, 'presentation_unit_type_id': unitTypeId, }; } _CartTotals _calculateTotals() { var taxed = 0.0; var unaffected = 0.0; var exonerated = 0.0; var value = 0.0; var igv = 0.0; var total = 0.0; for (final line in _cart) { final row = _calculateLine(line); value += row.totalValue; igv += row.totalIgv; total += row.total; if (_isTaxed(row.affectationIgvTypeId)) { taxed += row.totalValue; } else if (_isExonerated(row.affectationIgvTypeId)) { exonerated += row.totalValue; } else { unaffected += row.totalValue; } } return _CartTotals( taxed: _round2(taxed), unaffected: _round2(unaffected), exonerated: _round2(exonerated), value: _round2(value), igv: _round2(igv), total: _round2(total), ); } _CalculatedLine _calculateLine(_CartLine line) { final affectationIgvTypeId = line.item['sale_affectation_igv_type_id']?.toString() ?? '10'; final quantity = line.quantity; final unitPrice = line.unitPrice; const percentageIgv = 18.0; final taxed = _isTaxed(affectationIgvTypeId); final unitValue = taxed ? unitPrice / 1.18 : unitPrice; final totalValue = unitValue * quantity; final totalIgv = taxed ? totalValue * 0.18 : 0.0; final totalTaxes = totalIgv; final total = totalValue + totalTaxes; return _CalculatedLine( affectationIgvTypeId: affectationIgvTypeId, percentageIgv: percentageIgv, unitValue: _round4(unitValue), totalBaseIgv: taxed ? _round2(totalValue) : 0, totalValue: _round2(totalValue), totalIgv: _round2(totalIgv), totalTaxes: _round2(totalTaxes), total: _round2(total), ); } double _saleUnitPriceForCart(Map item) { final unitTypes = _itemUnitTypes(item); final selected = _defaultPresentationForItem(item, unitTypes); final basePrice = selected == null ? _asDouble(item['sale_unit_price']) : _presentationUnitPrice(item, selected); return _normalizeSalePrice(item, basePrice); } List> _itemUnitTypes(Map item) { return List>.from( item['item_unit_types'] as List? ?? const []); } Map? _defaultPresentationForItem( Map item, List> unitTypes, ) { if (unitTypes.isEmpty) { return null; } final defaultIndex = unitTypes.indexWhere((row) { final priceDefault = int.tryParse(row['price_default']?.toString() ?? ''); return priceDefault == 1 || priceDefault == 2 || priceDefault == 3; }); if (defaultIndex >= 0) { return unitTypes[defaultIndex]; } final baseUnitTypeId = item['unit_type_id']?.toString(); final baseIndex = unitTypes.indexWhere( (row) => row['unit_type_id']?.toString() == baseUnitTypeId, ); if (baseIndex >= 0) { return unitTypes[baseIndex]; } return unitTypes.first; } double _presentationUnitPrice( Map item, Map unitType, ) { final priceDefault = int.tryParse(unitType['price_default']?.toString() ?? ''); final price1 = _asDouble(unitType['price1']); final price2 = _asDouble(unitType['price2']); final price3 = _asDouble(unitType['price3']); final selected = switch (priceDefault) { 2 => price2, 3 => price3, _ => price1, }; if (selected > 0) { return selected; } if (price1 > 0) return price1; if (price2 > 0) return price2; if (price3 > 0) return price3; return _asDouble(item['sale_unit_price']); } double _normalizeSalePrice(Map item, double basePrice) { final hasIgv = item['has_igv'] == true; final affectation = item['sale_affectation_igv_type_id']?.toString() ?? '10'; if (_isTaxed(affectation) && !hasIgv) { return _round4(basePrice * 1.18); } return _round4(basePrice); } Future<_PresentationSelection?> _pickPresentation( Map item, ) async { final unitTypes = _itemUnitTypes(item); if (unitTypes.isEmpty) { return null; } if (unitTypes.length == 1) { final row = unitTypes.first; return _PresentationSelection( unitTypeId: row['unit_type_id']?.toString(), description: row['description']?.toString() ?? 'Presentacion', quantityUnit: _asDouble(row['quantity_unit']).clamp(1, 999999).toDouble(), unitPrice: _normalizeSalePrice( item, _presentationUnitPrice(item, row), ), ); } return showModalBottomSheet<_PresentationSelection>( context: context, isScrollControlled: true, builder: (context) { return SafeArea( child: ListView.separated( padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), shrinkWrap: true, itemCount: unitTypes.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, index) { final row = unitTypes[index]; final description = row['description']?.toString() ?? 'Presentacion'; final quantityUnit = _asDouble(row['quantity_unit']).clamp(1, 999999).toDouble(); final unitPrice = _normalizeSalePrice( item, _presentationUnitPrice(item, row), ); final unitTypeId = row['unit_type_id']?.toString(); return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 8), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTokens.radiusMedium), side: const BorderSide(color: AppTokens.border), ), title: Text(description), subtitle: Text('x${_formatNumber(quantityUnit)}'), trailing: Text('PEN ${unitPrice.toStringAsFixed(2)}'), onTap: () { Navigator.of(context).pop( _PresentationSelection( unitTypeId: unitTypeId, description: description, quantityUnit: quantityUnit, unitPrice: unitPrice, ), ); }, ); }, ), ); }, ); } bool _isTaxed(String affectationIgvTypeId) { return affectationIgvTypeId == '10'; } bool _isExonerated(String affectationIgvTypeId) { return affectationIgvTypeId.startsWith('2'); } double _asDouble(dynamic value) { if (value is num) { return value.toDouble(); } return double.tryParse(value?.toString() ?? '') ?? 0; } int? _asInt(dynamic value) { if (value is int) { return value; } return int.tryParse(value?.toString() ?? ''); } double _round2(double value) { return double.parse(value.toStringAsFixed(2)); } double _round4(double value) { return double.parse(value.toStringAsFixed(4)); } String _formatNumber(double value) { if (value == value.roundToDouble()) { return value.toStringAsFixed(0); } return value.toStringAsFixed(2); } String _formatDate(DateTime value) { final month = value.month.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0'); return '${value.year}-$month-$day'; } String _formatTime(DateTime value) { final hour = value.hour.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0'); final second = value.second.toString().padLeft(2, '0'); return '$hour:$minute:$second'; } Future _openCartSheet(_SalesBundle bundle) async { await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, builder: (context) { return StatefulBuilder( builder: (context, sheetSetState) { final totals = _calculateTotals(); return FractionallySizedBox( heightFactor: 0.9, child: Container( decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 12), child: Row( children: [ Container( width: 44, height: 5, decoration: BoxDecoration( color: AppTokens.border, borderRadius: BorderRadius.circular(999), ), ), const Spacer(), Text( 'Carrito', style: Theme.of(context).textTheme.titleLarge, ), const Spacer(), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close_rounded), ), ], ), ), Expanded( child: ListView( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [ if (_cart.isEmpty) const EmptyStatePanel( icon: Icons.shopping_cart_outlined, title: 'Sin productos en carrito', subtitle: 'Agrega productos desde el catalogo para calcular la venta.', ) else ...[ for (final line in _cart) ...[ AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _removingCartLines.contains(line) ? 0 : 1, child: AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, alignment: Alignment.topCenter, child: _removingCartLines.contains(line) ? const SizedBox.shrink() : _CartItemRow( line: line, total: _calculateLine(line).total, onIncrease: () { setState( () => _changeQuantity(line, 1)); sheetSetState(() {}); }, onDecrease: () { setState(() => _changeQuantity(line, -1)); sheetSetState(() {}); }, onRemove: () { sheetSetState(() => _removingCartLines.add(line)); Future.delayed( const Duration( milliseconds: 200), () { if (!mounted) return; setState(() { _cart.remove(line); _removingCartLines .remove(line); }); try { sheetSetState(() {}); } catch (_) { // el modal ya se cerro antes // de que termine el fade. } }); }, ), ), ), if (line != _cart.last) const SizedBox(height: 12), ], const SizedBox(height: 18), Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppTokens.background, borderRadius: BorderRadius.circular( AppTokens.radiusMedium), border: Border.all(color: AppTokens.border), ), child: Column( children: [ _InfoRow( label: 'Operaciones gravadas', value: _formatNumber(totals.taxed)), const SizedBox(height: 10), _InfoRow( label: 'Operaciones exoneradas', value: _formatNumber(totals.exonerated)), const SizedBox(height: 10), _InfoRow( label: 'Operaciones inafectas', value: _formatNumber(totals.unaffected)), const SizedBox(height: 10), _InfoRow( label: 'IGV', value: _formatNumber(totals.igv)), const Divider(height: 24), _InfoRow( label: 'Total a cobrar', value: 'PEN ${_formatNumber(totals.total)}', emphasize: true, ), ], ), ), ], ], ), ), if (_cart.isNotEmpty) SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: FilledButton.icon( style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), backgroundColor: AppTokens.cta, foregroundColor: Colors.white, ), onPressed: _isSubmitting ? null : () async { await _submitSale( bundle, onSubmittingChanged: () => sheetSetState(() {}), ); sheetSetState(() {}); }, icon: _isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.point_of_sale_rounded), label: Text(_isSubmitting ? 'Emitiendo...' : 'Emitir documento'), ), ), ), ], ), ), ); }, ); }, ); } @override Widget build(BuildContext context) { // Sincroniza si hay una venta en curso para que el shell raíz sepa si // debe confirmar antes de navegar/salir con el botón físico atrás. final hasCart = _cart.isNotEmpty; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final notifier = ref.read(hasUnsavedSaleCartProvider.notifier); if (notifier.state != hasCart) notifier.state = hasCart; }); return SafeArea( child: RefreshIndicator( onRefresh: _refresh, child: FutureBuilder<_SalesBundle>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const AppLoader(); } if (snapshot.hasError) { return ListView( padding: AppTokens.pagePadding, children: [ AppPanel( title: 'Error al cargar ventas', subtitle: snapshot.error.toString(), child: FilledButton.icon( onPressed: _refresh, icon: const Icon(Icons.refresh_rounded), label: const Text('Reintentar'), ), ), ], ); } final bundle = snapshot.data; if (bundle == null || !bundle.isReady) { return ListView( padding: AppTokens.pagePadding, children: [ AppPanel( title: 'Ventas', subtitle: bundle?.firstError ?? 'No se pudo cargar la configuracion comercial.', child: const Text( 'Revisa autenticacion, conectividad y datos base del tenant.'), ), ], ); } final filteredItems = _filteredItems(bundle); final navBarBottom = MediaQuery.of(context).padding.bottom; final totals = _calculateTotals(); return Stack( children: [ ListView( controller: _scrollController, padding: AppTokens.pagePadding .copyWith(bottom: navBarBottom + 110), children: [ // Cliente compacto en una sola línea InkWell( borderRadius: BorderRadius.circular(AppTokens.radiusMedium), onTap: () => _selectCustomer(bundle), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusMedium), border: Border.all(color: AppTokens.border), ), child: Row( children: [ CircleAvatar( radius: 16, backgroundColor: AppTokens.primary.withValues(alpha: 0.12), foregroundColor: AppTokens.primary, child: const Icon(Icons.person_rounded, size: 18), ), const SizedBox(width: 10), Expanded( child: Text( _selectedCustomer?['name']?.toString() ?? 'Seleccionar cliente', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, ), ), const Icon(Icons.edit_rounded, size: 18, color: AppTokens.secondary), ], ), ), ), const SizedBox(height: 12), // Buscador con scanner Row( children: [ Expanded( child: TextField( controller: _searchController, decoration: const InputDecoration( labelText: 'Buscar producto', prefixIcon: Icon(Icons.search_rounded), ), ), ), const SizedBox(width: 8), IconButton.filledTonal( onPressed: _scanSalesBarcode, icon: const Icon(Icons.qr_code_scanner_rounded), tooltip: 'Escanear código', ), const SizedBox(width: 8), IconButton.filledTonal( onPressed: () => _openOptionsSheet(bundle), icon: const Icon(Icons.tune_rounded), tooltip: 'Opciones del comprobante', ), ], ), const SizedBox(height: 12), if (_pendingSalesCount > 0 || _isSyncingPending) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: AppTokens.warning.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all( color: AppTokens.warning.withValues(alpha: 0.40), ), ), child: Row( children: [ const Icon(Icons.cloud_sync_rounded, color: AppTokens.warning, size: 18), const SizedBox(width: 8), Expanded( child: Text( _isSyncingPending ? 'Sincronizando ventas pendientes...' : 'Ventas pendientes: $_pendingSalesCount', style: Theme.of(context).textTheme.bodyMedium, ), ), TextButton( onPressed: _isSyncingPending ? null : _syncPendingSales, child: const Text('Sincronizar ahora'), ), IconButton( onPressed: () => context.go('/pending-sync'), icon: const Icon(Icons.chevron_right_rounded), tooltip: 'Ver detalle', ), ], ), ), const SizedBox(height: 12), ], // Catálogo if (_isLoadingItems) const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (filteredItems.isEmpty) const EmptyStatePanel( icon: Icons.inventory_2_outlined, title: 'Sin productos visibles', subtitle: 'Ajusta la búsqueda o revisa el catálogo cargado en el tenant.', ) else Column( key: _catalogKey, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final item in filteredItems) ...[ _CatalogRow( item: item, price: _saleUnitPriceForCart(item), onAdd: () => _addItem(item), ), if (item != filteredItems.last) const SizedBox(height: 8), ], if (_itemsHasMore) ...[ const SizedBox(height: 14), OutlinedButton.icon( onPressed: _isLoadingItems ? null : _loadMoreItems, icon: _isLoadingItems ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2), ) : const Icon(Icons.expand_more_rounded), label: Text( _isLoadingItems ? 'Cargando...' : 'Cargar más productos', ), ), ], ], ), ], ), // Barra inferior fija con total + cobrar Positioned( left: 0, right: 0, bottom: 0, child: Container( padding: EdgeInsets.fromLTRB(16, 12, 16, navBarBottom + 12), decoration: BoxDecoration( color: AppTokens.surface, border: const Border( top: BorderSide(color: AppTokens.border), ), boxShadow: [ BoxShadow( color: AppTokens.textPrimary.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, -2), ), ], ), child: Row( children: [ Expanded( child: InkWell( onTap: _cart.isEmpty || _suppressCartSheet ? null : () => _openCartSheet(bundle), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: Text( _cart.isEmpty ? 'Sin items' : '${_cart.length} item(s)', key: ValueKey(_cart.length), style: Theme.of(context) .textTheme .bodySmall ?.copyWith( color: AppTokens.secondary), ), ), const SizedBox(height: 2), TweenAnimationBuilder( tween: Tween( begin: 0, end: totals.total), duration: const Duration(milliseconds: 300), curve: Curves.easeOut, builder: (context, value, child) => Text( 'S/ ${value.toStringAsFixed(2)}', style: Theme.of(context) .textTheme .titleLarge ?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w800, ), ), ), ], ), ), ), ), FilledButton.icon( onPressed: (_cart.isEmpty || _isSubmitting) ? null : () => _submitSale(bundle), icon: _isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.point_of_sale_rounded), label: Text(_isSubmitting ? 'Procesando...' : 'Cobrar'), style: FilledButton.styleFrom( backgroundColor: AppTokens.cta, padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 14), ), ), ], ), ), ), ], ); }, ), ), ); } } class _PaymentMethodTile extends StatelessWidget { const _PaymentMethodTile({ required this.method, required this.controller, required this.isActive, required this.isSelected, required this.isCash, required this.reference, required this.onTap, required this.onAmountChanged, required this.onReferenceTap, }); final Map method; final TextEditingController controller; final bool isActive; final bool isSelected; final bool isCash; final String? reference; final VoidCallback onTap; final ValueChanged onAmountChanged; final VoidCallback onReferenceTap; IconData _iconFor(String? id, String? description) { final desc = (description ?? '').toLowerCase(); if (desc.contains('efectivo') || id == '01') { return Icons.payments_rounded; } if (desc.contains('yape')) return Icons.qr_code_rounded; if (desc.contains('plin')) return Icons.qr_code_2_rounded; if (desc.contains('visa') || desc.contains('tarjeta') || desc.contains('mastercard')) { return Icons.credit_card_rounded; } if (desc.contains('transfer') || desc.contains('deposito')) { return Icons.account_balance_rounded; } return Icons.payments_outlined; } @override Widget build(BuildContext context) { final id = method['id']?.toString(); final desc = method['description']?.toString() ?? 'Método'; final borderColor = isSelected ? AppTokens.primary : isActive ? AppTokens.primary.withValues(alpha: 0.40) : AppTokens.border; final bg = isActive ? AppTokens.primary.withValues(alpha: 0.06) : AppTokens.surface; return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all( color: borderColor, width: isSelected ? 2 : 1, ), ), child: Row( children: [ Icon( _iconFor(id, desc), color: isActive ? AppTokens.primary : AppTokens.secondary, size: 22, ), const SizedBox(width: 12), Expanded( child: Text( desc, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: isActive ? FontWeight.w700 : FontWeight.w500, color: isActive ? AppTokens.textPrimary : AppTokens.secondary, ), ), ), const SizedBox(width: 8), Text( 'S/', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), const SizedBox(width: 4), SizedBox( width: 90, child: TextField( controller: controller, textAlign: TextAlign.right, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, color: AppTokens.primary, ), decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), border: OutlineInputBorder(), ), onTap: () { onTap(); // Selecciona todo el texto para que el siguiente dígito reemplace, // no se concatene a "0.00". controller.selection = TextSelection( baseOffset: 0, extentOffset: controller.text.length, ); }, onChanged: onAmountChanged, ), ), if (isActive && !isCash) ...[ const SizedBox(width: 4), IconButton( onPressed: onReferenceTap, icon: Icon( (reference ?? '').isNotEmpty ? Icons.receipt_long_rounded : Icons.receipt_outlined, color: (reference ?? '').isNotEmpty ? AppTokens.primary : AppTokens.secondary, size: 20, ), tooltip: 'Referencia', padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), ], ], ), ), ), ); } } class _SalesBundle { const _SalesBundle({ required this.tables, required this.tablesSuccess, required this.tablesMessage, required this.items, required this.itemsSuccess, required this.itemsMessage, required this.customers, required this.customersSuccess, required this.customersMessage, }); final Map tables; final bool tablesSuccess; final String? tablesMessage; final List> items; final bool itemsSuccess; final String? itemsMessage; final List> customers; final bool customersSuccess; final String? customersMessage; bool get isReady => tablesSuccess && itemsSuccess && customersSuccess; String? get firstError { if (!tablesSuccess) return tablesMessage ?? 'Error al cargar tablas.'; if (!itemsSuccess) return itemsMessage ?? 'Error al cargar productos.'; if (!customersSuccess) { return customersMessage ?? 'Error al cargar clientes.'; } return null; } List> get documentTypes { final saleDetail = Map.from(tables['sale_detail'] as Map? ?? const {}); final types = List>.from( saleDetail['document_types'] as List? ?? const []); final hasSaleNote = types.any( (row) => row['id']?.toString() == '80', ); if (!hasSaleNote) { types.add({ 'id': '80', 'description': 'Nota de venta', }); } return types; } List> get series { final salePayment = Map.from(tables['sale_payment'] as Map? ?? const {}); return List>.from( salePayment['series'] as List? ?? const []); } List> get paymentConditions { final salePayment = Map.from(tables['sale_payment'] as Map? ?? const {}); return List>.from( salePayment['payment_conditions'] as List? ?? const []); } List> get paymentMethodTypes { final salePayment = Map.from(tables['sale_payment'] as Map? ?? const {}); return List>.from( salePayment['payment_method_types'] as List? ?? const []); } String paymentMethodLabel(String? id) { if (id == null || id.trim().isEmpty) { return 'Pago'; } for (final method in paymentMethodTypes) { if (method['id']?.toString() == id) { return method['description']?.toString() ?? id; } } return id; } List> get paymentDestinations { final salePayment = Map.from(tables['sale_payment'] as Map? ?? const {}); return List>.from( salePayment['payment_destinations'] as List? ?? const []); } List> seriesFor(String? documentTypeId) { if (documentTypeId == null) { return series; } final filtered = series .where((row) => row['document_type_id']?.toString() == documentTypeId) .toList(); return filtered; } Map? findDocumentType(String? id) { if (id == null) { return null; } try { return documentTypes.firstWhere((row) => row['id']?.toString() == id); } on StateError { return null; } } } class _CartLine { _CartLine({ required this.item, required this.quantity, required this.unitPrice, required this.quantityFactor, this.presentationUnitTypeId, this.presentationDescription, }); final Map item; double quantity; double unitPrice; double quantityFactor; String? presentationUnitTypeId; String? presentationDescription; int get itemId => int.tryParse(item['id']?.toString() ?? '') ?? 0; } class _PresentationSelection { const _PresentationSelection({ required this.unitTypeId, required this.description, required this.quantityUnit, required this.unitPrice, }); final String? unitTypeId; final String description; final double quantityUnit; final double unitPrice; } class _CalculatedLine { const _CalculatedLine({ required this.affectationIgvTypeId, required this.percentageIgv, required this.unitValue, required this.totalBaseIgv, required this.totalValue, required this.totalIgv, required this.totalTaxes, required this.total, }); final String affectationIgvTypeId; final double percentageIgv; final double unitValue; final double totalBaseIgv; final double totalValue; final double totalIgv; final double totalTaxes; final double total; } class _CartTotals { const _CartTotals({ required this.taxed, required this.unaffected, required this.exonerated, required this.value, required this.igv, required this.total, }); final double taxed; final double unaffected; final double exonerated; final double value; final double igv; final double total; } class _CatalogRow extends StatelessWidget { const _CatalogRow({ required this.item, required this.price, required this.onAdd, }); final Map item; final double price; final VoidCallback onAdd; @override Widget build(BuildContext context) { final stockValue = item['stock']; final hasStock = stockValue != null && stockValue.toString().trim().isNotEmpty; final stock = double.tryParse(stockValue?.toString() ?? '') ?? 0; final unitType = item['unit_type_id']?.toString().toUpperCase(); final itemType = item['item_type_id']?.toString(); final isService = unitType == 'ZZ' || itemType == '02'; final canAdd = isService || stock > 0; final lotsEnabled = item['lots_enabled'] == true; final unitTypes = List>.from( item['item_unit_types'] as List? ?? const []); return Material( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: InkWell( onTap: canAdd ? onAdd : null, 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: AppTokens.border), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( item['description']?.toString() ?? 'Producto', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 2), Row( children: [ if (hasStock) ...[ Text( 'Stock ${stockValue.toString()}', style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: stock > 0 ? AppTokens.secondary : AppTokens.danger, ), ), const SizedBox(width: 8), ], if (unitTypes.length > 1) Text( '${unitTypes.length} pres.', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: AppTokens.secondary), ), if (lotsEnabled) ...[ if (unitTypes.length > 1) const SizedBox(width: 8), Text( 'Lotes', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: AppTokens.secondary), ), ], ], ), ], ), ), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text( 'S/ ${price.toStringAsFixed(2)}', style: Theme.of(context).textTheme.titleSmall?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), SizedBox( width: 36, height: 36, child: IconButton.filledTonal( padding: EdgeInsets.zero, onPressed: canAdd ? onAdd : null, icon: Icon( canAdd ? Icons.add_rounded : Icons.block_rounded, size: 20, ), tooltip: canAdd ? 'Agregar' : 'Sin stock', ), ), ], ), ], ), ), ), ); } } class _CartItemRow extends StatelessWidget { const _CartItemRow({ required this.line, required this.total, required this.onIncrease, required this.onDecrease, required this.onRemove, }); final _CartLine line; final double total; final VoidCallback onIncrease; final VoidCallback onDecrease; final VoidCallback onRemove; String _formatNumber(double value) { if (value == value.roundToDouble()) { return value.toStringAsFixed(0); } return value.toStringAsFixed(2); } @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusMedium), border: Border.all(color: AppTokens.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( line.item['description']?.toString() ?? 'Producto', maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text( line.item['internal_id']?.toString() ?? 'Sin codigo', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.secondary, ), ), if (line.presentationDescription != null) ...[ const SizedBox(height: 4), Text( 'Presentacion: ${line.presentationDescription} x${_formatNumber(line.quantityFactor)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), ], ], ), ), IconButton( onPressed: onRemove, icon: const Icon(Icons.delete_outline_rounded), tooltip: 'Quitar', ), ], ), const SizedBox(height: 12), Row( children: [ _StepperButton(icon: Icons.remove_rounded, onTap: onDecrease), Container( width: 54, alignment: Alignment.center, child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), transitionBuilder: (child, animation) => ScaleTransition( scale: animation, child: FadeTransition(opacity: animation, child: child), ), child: Text( line.quantity == line.quantity.roundToDouble() ? line.quantity.toStringAsFixed(0) : line.quantity.toStringAsFixed(2), key: ValueKey(line.quantity), style: Theme.of(context).textTheme.titleMedium, ), ), ), _StepperButton(icon: Icons.add_rounded, onTap: onIncrease), const Spacer(), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( 'PEN ${line.unitPrice.toStringAsFixed(2)} c/u', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.secondary, ), ), const SizedBox(height: 4), AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: Text( 'PEN ${total.toStringAsFixed(2)}', key: ValueKey(total), style: Theme.of(context).textTheme.titleMedium, ), ), ], ), ], ), ], ), ); } } class _StepperButton extends StatefulWidget { const _StepperButton({ required this.icon, required this.onTap, }); final IconData icon; final VoidCallback onTap; @override State<_StepperButton> createState() => _StepperButtonState(); } class _StepperButtonState extends State<_StepperButton> { bool _pressed = false; @override Widget build(BuildContext context) { return GestureDetector( onTap: widget.onTap, onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), child: AnimatedScale( scale: _pressed ? 0.85 : 1, duration: const Duration(milliseconds: 100), curve: Curves.easeOut, child: Container( width: 36, height: 36, decoration: BoxDecoration( color: AppTokens.surface, shape: BoxShape.circle, border: Border.all(color: AppTokens.border), ), child: Icon(widget.icon, size: 18, color: AppTokens.textPrimary), ), ), ); } } class _InfoRow extends StatelessWidget { const _InfoRow({ required this.label, required this.value, this.emphasize = false, }); final String label; final String value; final bool emphasize; @override Widget build(BuildContext context) { final valueStyle = emphasize ? Theme.of(context).textTheme.titleLarge : Theme.of(context).textTheme.titleMedium; return Row( children: [ Expanded( child: Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.secondary, ), ), ), const SizedBox(width: 12), Expanded( child: Text( value, textAlign: TextAlign.end, overflow: TextOverflow.ellipsis, style: valueStyle, ), ), ], ); } }