import 'dart:async'; import 'package:sysfarma_mobile/src/app/widgets/app_hero_card.dart'; 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/mobile_api_response.dart'; import 'package:sysfarma_mobile/src/core/theme/app_tokens.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; String _formatCurrency(Object? value) { if (value == null) return 'S/ 0.00'; final n = double.tryParse(value.toString()) ?? 0.0; final formatted = n.toStringAsFixed(2).replaceAllMapped( RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},', ); return 'S/ $formatted'; } class InventoryScreen extends ConsumerStatefulWidget { const InventoryScreen({super.key}); @override ConsumerState createState() => _InventoryScreenState(); } /// Filtro activo para la lista de inventario. enum _StockFilter { all, outOfStock, lowStock } class _InventoryScreenState extends ConsumerState { static const int _pageSize = 15; late Future<_InventoryBundle> _future; final ScrollController _scrollController = ScrollController(); // Paginación acumulada de items final List> _accumulatedItems = []; int _currentPage = 1; bool _hasMore = true; bool _isLoadingMore = false; // Filtro activo _StockFilter _activeFilter = _StockFilter.all; @override void initState() { super.initState(); _future = _load(); } @override void dispose() { _scrollController.dispose(); super.dispose(); } Future<_InventoryBundle> _load() async { try { _currentPage = 1; _accumulatedItems.clear(); _hasMore = true; final api = ref.read(apiClientProvider); final stock = await api.inventoryStock(page: 1, perPage: _pageSize); final options = await api.inventoryOptions(); // Almacenar la primera página en el acumulado if (stock.success) { _accumulatedItems.addAll(stock.data ?? const []); _hasMore = (stock.meta['has_more'] == true); } return _InventoryBundle( stock: stock, options: options, ); } catch (e) { return _InventoryBundle( stock: MobileApiResponse.failure(message: e.toString()), options: MobileApiResponse.failure(message: e.toString()), ); } } Future _loadMore() async { if (_isLoadingMore || !_hasMore) return; setState(() => _isLoadingMore = true); try { final api = ref.read(apiClientProvider); final nextPage = _currentPage + 1; final response = await api.inventoryStock(page: nextPage, perPage: _pageSize); if (!mounted) return; if (response.success) { setState(() { _accumulatedItems.addAll(response.data ?? const []); _currentPage = nextPage; _hasMore = (response.meta['has_more'] == true); _isLoadingMore = false; }); } else { setState(() => _isLoadingMore = false); } } catch (_) { if (mounted) setState(() => _isLoadingMore = false); } } Future _refresh() async { final future = _load(); setState(() { _future = future; _activeFilter = _StockFilter.all; }); await future; } List> _filteredItems() { switch (_activeFilter) { case _StockFilter.outOfStock: return _accumulatedItems .where((i) => _toDouble(i['stock']) <= 0) .toList(); case _StockFilter.lowStock: return _accumulatedItems .where((i) => _toDouble(i['stock']) > 0 && _toDouble(i['stock']) <= _toDouble(i['stock_min'])) .toList(); case _StockFilter.all: return _accumulatedItems; } } void _toggleFilter(_StockFilter filter) { setState(() { _activeFilter = _activeFilter == filter ? _StockFilter.all : filter; }); } void _showMessage(String message, {bool isError = false}) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: isError ? AppTokens.danger : null, ), ); } List> _transactionsForType( List> transactions, String type, ) { return transactions.where((item) => item['type']?.toString() == type).toList(); } Future _showItemDetailSheet(Map item) async { final stock = double.tryParse('${item['stock']}') ?? 0; final stockMin = double.tryParse('${item['stock_min']}') ?? 0; final riskColor = stock <= 0 ? AppTokens.danger : stock <= stockMin ? AppTokens.warning : AppTokens.success; final statusLabel = stock <= 0 ? 'Agotado' : stock <= stockMin ? 'Stock bajo' : 'Disponible'; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetContext) { return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ CircleAvatar( radius: 24, backgroundColor: riskColor.withValues(alpha: 0.12), foregroundColor: riskColor, child: const Icon(Icons.medication_liquid_rounded), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item['description']?.toString() ?? 'Producto', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: riskColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), ), child: Text( statusLabel, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: riskColor, fontWeight: FontWeight.w700, ), ), ), ], ), ), ], ), const SizedBox(height: 20), _DetailRow( icon: Icons.inventory_2_rounded, label: 'Stock actual', value: '${item['stock'] ?? 0} uds', color: riskColor, ), const Divider(height: 24), _DetailRow( icon: Icons.warning_amber_rounded, label: 'Stock mínimo', value: '${item['stock_min'] ?? 0} uds', ), const Divider(height: 24), _DetailRow( icon: Icons.shopping_bag_rounded, label: 'Precio compra', value: _formatCurrency(item['purchase_unit_price']), ), const Divider(height: 24), _DetailRow( icon: Icons.sell_rounded, label: 'Precio venta', value: _formatCurrency(item['sale_unit_price']), color: AppTokens.primary, ), const Divider(height: 24), _DetailRow( icon: Icons.account_balance_wallet_rounded, label: 'Costo en inventario', value: _formatCurrency(item['stock_cost_value']), ), const Divider(height: 24), _DetailRow( icon: Icons.point_of_sale_rounded, label: 'Valor de venta', value: _formatCurrency(item['stock_sale_value']), ), const Divider(height: 24), _DetailRow( icon: Icons.trending_up_rounded, label: 'Utilidad potencial', value: _formatCurrency(item['potential_profit']), color: riskColor, ), if ((item['category_name']?.toString() ?? '').isNotEmpty) ...[ const Divider(height: 24), _DetailRow( icon: Icons.category_rounded, label: 'Categoría', value: item['category_name'].toString(), ), ], if ((item['brand_name']?.toString() ?? '').isNotEmpty) ...[ const Divider(height: 24), _DetailRow( icon: Icons.bookmark_rounded, label: 'Marca', value: item['brand_name'].toString(), ), ], const SizedBox(height: 20), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () { Navigator.of(sheetContext).pop(); }, icon: const Icon(Icons.close_rounded), label: const Text('Cerrar'), ), ), ], ), ), ); }, ); } Future _openMovementForm({ required List> warehouses, required List> transactions, Map? preSelectedItem, }) async { if (warehouses.isEmpty || transactions.isEmpty) { _showMessage('No hay almacenes o transacciones disponibles para registrar movimientos.', isError: true); return; } final preCode = preSelectedItem?['internal_id']?.toString(); final preItemId = preSelectedItem != null ? int.tryParse('${preSelectedItem['item_id'] ?? preSelectedItem['id']}') : null; final preName = preSelectedItem?['description']?.toString() ?? preSelectedItem?['full_description']?.toString(); final preLotsEnabled = preSelectedItem?['lots_enabled'] == true; final preText = (preName != null && preName.isNotEmpty) ? (preCode != null && preCode.isNotEmpty ? '$preCode - $preName' : preName) : (preCode ?? ''); final itemSearchController = TextEditingController(text: preText); final quantityController = TextEditingController(); final lotCodeController = TextEditingController(); final dateOfDueController = TextEditingController(); final formKey = GlobalKey(); String selectedType = 'input'; int? selectedWarehouseId = int.tryParse('${warehouses.first['id']}'); final initialTransactions = _transactionsForType(transactions, 'input'); int? selectedTransactionId = initialTransactions.isEmpty ? null : int.tryParse('${initialTransactions.first['id']}'); String? errorText; var isSubmitting = false; // Search state String? selectedItemCode = preCode; int? selectedItemId = preItemId; String? selectedItemName = preName; bool selectedItemLotsEnabled = preLotsEnabled; List> searchResults = []; bool isSearching = false; Timer? searchDebounce; // Autocompletado de lotes ya registrados del producto seleccionado. List> lotSuggestions = []; bool isSearchingLots = false; bool showLotSuggestions = false; Timer? lotSearchDebounce; Future searchLots(String query, void Function(void Function()) setSheetState) async { if (selectedItemId == null) return; setSheetState(() => isSearchingLots = true); final response = await ref.read(apiClientProvider).itemLots( itemId: selectedItemId!, query: query, ); setSheetState(() { isSearchingLots = false; lotSuggestions = response.success ? (response.data ?? const []) : const []; }); } await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { return StatefulBuilder( builder: (context, setSheetState) { final currentTransactions = _transactionsForType(transactions, selectedType); selectedTransactionId ??= currentTransactions.isEmpty ? null : int.tryParse('${currentTransactions.first['id']}'); Future submit() async { if (!(formKey.currentState?.validate() ?? false) || isSubmitting) { return; } setSheetState(() { isSubmitting = true; errorText = null; }); // Validación de lote obligatorio (código + fecha de vencimiento, // igual que en la web). if (selectedItemLotsEnabled && selectedType == 'input') { if (lotCodeController.text.trim().isEmpty) { setSheetState(() { isSubmitting = false; errorText = 'Este producto requiere código de lote.'; }); return; } if (dateOfDueController.text.trim().isEmpty) { setSheetState(() { isSubmitting = false; errorText = 'Ingresa la fecha de vencimiento del lote.'; }); return; } } final response = await ref.read(apiClientProvider).createInventoryMovement( itemCode: selectedItemCode, itemId: selectedItemId, warehouseId: selectedWarehouseId ?? 0, inventoryTransactionId: selectedTransactionId ?? 0, quantity: double.tryParse(quantityController.text.trim()) ?? 0, type: selectedType, lotCode: lotCodeController.text.trim().isEmpty ? null : lotCodeController.text.trim(), dateOfDue: dateOfDueController.text.trim().isEmpty ? null : dateOfDueController.text.trim(), ); if (!response.success) { setSheetState(() { isSubmitting = false; errorText = response.message ?? 'No se pudo registrar el movimiento.'; }); return; } // Disparar el refresh ANTES de cerrar el sheet para que el State // del screen actualice su future inmediatamente. // No await aquí para no bloquear el pop. // ignore: discarded_futures _refresh(); if (!context.mounted) return; if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } _showMessage(response.message ?? 'Movimiento registrado.'); } 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: [ Text('Nuevo movimiento', style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 8), Text( 'Registra ingresos o salidas usando el API movil real de inventario.', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppTokens.secondary), ), const SizedBox(height: 18), DropdownButtonFormField( initialValue: selectedType, isExpanded: true, items: const [ DropdownMenuItem(value: 'input', child: Text('Ingreso', overflow: TextOverflow.ellipsis)), DropdownMenuItem(value: 'output', child: Text('Salida', overflow: TextOverflow.ellipsis)), ], onChanged: (value) { if (value == null) { return; } setSheetState(() { selectedType = value; final filtered = _transactionsForType(transactions, value); selectedTransactionId = filtered.isEmpty ? null : int.tryParse('${filtered.first['id']}'); }); }, decoration: const InputDecoration( labelText: 'Tipo de movimiento', prefixIcon: Icon(Icons.swap_vert_rounded), ), ), // Con un solo almacén disponible (el caso normal: cada // usuario ya está atado a su sede) no tiene sentido // mostrar un selector — se usa directo y sin fricción. // Solo un admin/owner con varios almacenes ve el combo. if (warehouses.length > 1) ...[ const SizedBox(height: 14), DropdownButtonFormField( initialValue: selectedWarehouseId, isExpanded: true, items: warehouses .map( (item) => DropdownMenuItem( value: int.tryParse('${item['id']}'), child: Text( item['description']?.toString() ?? 'Almacen', overflow: TextOverflow.ellipsis, ), ), ) .toList(), onChanged: (value) => setSheetState(() => selectedWarehouseId = value), decoration: const InputDecoration( labelText: 'Almacen', prefixIcon: Icon(Icons.warehouse_rounded), ), ), ], const SizedBox(height: 14), DropdownButtonFormField( initialValue: selectedTransactionId, isExpanded: true, items: currentTransactions .map( (item) => DropdownMenuItem( value: int.tryParse('${item['id']}'), child: Text( item['name']?.toString() ?? 'Transaccion', overflow: TextOverflow.ellipsis, ), ), ) .toList(), onChanged: (value) => setSheetState(() => selectedTransactionId = value), decoration: const InputDecoration( labelText: 'Transaccion', prefixIcon: Icon(Icons.category_rounded), ), validator: (value) { if (value == null) { return 'Selecciona una transaccion.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: itemSearchController, decoration: InputDecoration( labelText: 'Buscar producto (nombre o código)', hintText: 'Escribe al menos 2 caracteres...', prefixIcon: const Icon(Icons.search_rounded), suffixIcon: (selectedItemId != null || selectedItemCode != null) ? IconButton( icon: const Icon(Icons.clear_rounded), onPressed: () { setSheetState(() { itemSearchController.clear(); selectedItemCode = null; selectedItemId = null; selectedItemName = null; selectedItemLotsEnabled = false; lotCodeController.clear(); dateOfDueController.clear(); lotSuggestions = []; showLotSuggestions = false; searchResults = []; }); }, ) : (isSearching ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ) : null), ), onChanged: (value) { // Si hay un item ya seleccionado y el usuario cambia el texto, // limpiar la selección if (selectedItemId != null || selectedItemCode != null) { setSheetState(() { selectedItemCode = null; selectedItemId = null; selectedItemName = null; selectedItemLotsEnabled = false; }); } searchDebounce?.cancel(); final query = value.trim(); if (query.length < 2) { setSheetState(() { searchResults = []; isSearching = false; }); return; } setSheetState(() => isSearching = true); searchDebounce = Timer( const Duration(milliseconds: 400), () async { final api = ref.read(apiClientProvider); final response = await api.items( perPage: 10, page: 1, search: query, context: 'inventory', ); setSheetState(() { isSearching = false; searchResults = response.success ? (response.data ?? const []) : const []; }); }, ); }, validator: (value) { if ((value == null || value.trim().isEmpty) && selectedItemCode == null) { return 'Busca y selecciona un producto.'; } return null; }, ), if (selectedItemName != null) ...[ const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: AppTokens.success.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(8), border: Border.all(color: AppTokens.success.withValues(alpha: 0.4)), ), child: Row( children: [ const Icon(Icons.check_circle_rounded, size: 16, color: AppTokens.success), const SizedBox(width: 8), Expanded( child: Text( selectedItemName!, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.textPrimary, fontWeight: FontWeight.w600, ), ), ), if (selectedItemLotsEnabled) Container( margin: const EdgeInsets.only(left: 8), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppTokens.warning.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: Text( 'LOTE', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppTokens.warning, fontWeight: FontWeight.w700, fontSize: 10, ), ), ), ], ), ), ], if (selectedItemLotsEnabled && selectedType == 'input') ...[ const SizedBox(height: 14), TextFormField( controller: lotCodeController, decoration: InputDecoration( labelText: 'Código de lote *', hintText: 'Ej: LOT-2026-001', prefixIcon: const Icon(Icons.qr_code_2_rounded), helperText: 'Este producto requiere lote. Toca para ver lotes ya registrados.', suffixIcon: isSearchingLots ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ) : null, ), onTap: () { // Al tocar el campo, precargar los lotes ya // registrados de este producto (igual que el // buscador de lotes de la web). setSheetState(() => showLotSuggestions = true); if (lotSuggestions.isEmpty) { // ignore: discarded_futures searchLots(lotCodeController.text.trim(), setSheetState); } }, onChanged: (value) { setSheetState(() => showLotSuggestions = true); lotSearchDebounce?.cancel(); lotSearchDebounce = Timer( const Duration(milliseconds: 350), () => searchLots(value.trim(), setSheetState), ); }, validator: (value) { if (selectedItemLotsEnabled && selectedType == 'input' && (value == null || value.trim().isEmpty)) { return 'Ingresa el código del lote.'; } return null; }, ), if (showLotSuggestions && lotSuggestions.isNotEmpty) ...[ const SizedBox(height: 6), Container( constraints: const BoxConstraints(maxHeight: 200), decoration: BoxDecoration( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all(color: AppTokens.border), ), child: ListView.separated( shrinkWrap: true, padding: EdgeInsets.zero, itemCount: lotSuggestions.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final lot = lotSuggestions[index]; final code = lot['code']?.toString() ?? ''; final due = lot['date_of_due']?.toString(); final qty = lot['quantity']; return ListTile( dense: true, leading: const Icon(Icons.qr_code_2_rounded, size: 20), title: Text(code, style: Theme.of(context).textTheme.bodyMedium), subtitle: Text( [ if (due != null && due.isNotEmpty) 'Vence: $due', if (qty != null) 'Stock: $qty', ].join(' · '), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.textSecondary, ), ), onTap: () { setSheetState(() { lotCodeController.text = code; dateOfDueController.text = due ?? ''; showLotSuggestions = false; }); FocusScope.of(context).unfocus(); }, ); }, ), ), ], const SizedBox(height: 14), TextFormField( controller: dateOfDueController, readOnly: true, decoration: const InputDecoration( labelText: 'Fecha de vencimiento *', hintText: 'YYYY-MM-DD', prefixIcon: Icon(Icons.event_rounded), ), onTap: () async { final picked = await showDatePicker( context: context, initialDate: DateTime.now().add(const Duration(days: 365)), firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365 * 10)), locale: const Locale('es'), helpText: 'Fecha de vencimiento', cancelText: 'Cancelar', confirmText: 'Aceptar', ); if (picked != null) { setSheetState(() { dateOfDueController.text = '${picked.year.toString().padLeft(4, '0')}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}'; }); } }, validator: (value) { if (selectedItemLotsEnabled && selectedType == 'input' && (value == null || value.trim().isEmpty)) { return 'Ingresa la fecha de vencimiento.'; } return null; }, ), ], if (searchResults.isNotEmpty && selectedItemCode == null) ...[ const SizedBox(height: 8), Container( constraints: const BoxConstraints(maxHeight: 240), decoration: BoxDecoration( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all(color: AppTokens.border), ), child: ListView.separated( shrinkWrap: true, padding: EdgeInsets.zero, itemCount: searchResults.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final item = searchResults[index]; final code = item['internal_id']?.toString() ?? ''; final description = item['description']?.toString() ?? item['full_description']?.toString() ?? 'Producto'; return ListTile( dense: true, leading: const Icon(Icons.inventory_2_rounded, size: 20), title: Text( description, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, ), subtitle: Text( 'Código: $code', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.textSecondary, ), ), onTap: () { setSheetState(() { selectedItemCode = code.isEmpty ? null : code; selectedItemId = int.tryParse('${item['id'] ?? item['item_id'] ?? ''}'); selectedItemName = description; selectedItemLotsEnabled = item['lots_enabled'] == true; itemSearchController.text = code.isNotEmpty ? '$code - $description' : description; searchResults = []; // Nuevo producto: los lotes del anterior ya no aplican. lotCodeController.clear(); dateOfDueController.clear(); lotSuggestions = []; showLotSuggestions = false; }); FocusScope.of(context).unfocus(); }, ); }, ), ), ], const SizedBox(height: 14), TextFormField( controller: quantityController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Cantidad', prefixIcon: Icon(Icons.scale_rounded), ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'Ingresa la cantidad.'; } final parsed = double.tryParse(value.trim()); if (parsed == null || parsed <= 0) { return 'Ingresa una cantidad valida.'; } return null; }, ), if (errorText != null) ...[ const SizedBox(height: 14), Text( errorText!, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: AppTokens.danger), ), ], const SizedBox(height: 20), FilledButton.icon( onPressed: isSubmitting ? null : submit, icon: isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : const Icon(Icons.save_rounded), label: Text(isSubmitting ? 'Guardando...' : 'Registrar movimiento'), ), ], ), ), ), ); }, ); }, ); } @override Widget build(BuildContext context) { return SafeArea( child: RefreshIndicator( onRefresh: _refresh, child: FutureBuilder<_InventoryBundle>( 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 inventario', subtitle: snapshot.error.toString(), child: FilledButton.icon( onPressed: _refresh, icon: const Icon(Icons.refresh_rounded), label: const Text('Reintentar'), ), ), ], ); } final bundle = snapshot.data; final stockResponse = bundle?.stock; final optionsResponse = bundle?.options; if (bundle == null || stockResponse == null || optionsResponse == null || !stockResponse.success || !optionsResponse.success) { return ListView( padding: AppTokens.pagePadding, children: [ AppPanel( title: 'Inventario', subtitle: stockResponse?.message ?? optionsResponse?.message ?? 'No se pudo cargar el módulo de inventario.', child: FilledButton.icon( onPressed: _refresh, icon: const Icon(Icons.refresh_rounded), label: const Text('Reintentar'), ), ), ], ); } final options = optionsResponse.data ?? const {}; final warehouses = List>.from(options['warehouses'] as List? ?? const []); final transactions = List>.from(options['transactions'] as List? ?? const []); final totalsRaw = stockResponse.meta['totals']; final totals = totalsRaw is Map ? Map.from(totalsRaw) : const {}; // lowStock y noStock del TOTAL del inventario (vienen en meta.totals) final lowStockCount = _toDouble(totals['low_stock_count']).toInt(); final noStockCount = _toDouble(totals['no_stock_count']).toInt(); // Fallback: si el meta no trae los contadores, contar localmente final fallbackLow = _accumulatedItems.where((item) => _toDouble(item['stock']) > 0 && _toDouble(item['stock']) <= _toDouble(item['stock_min'])).length; final fallbackNo = _accumulatedItems .where((item) => _toDouble(item['stock']) <= 0) .length; final displayLow = lowStockCount > 0 ? lowStockCount : fallbackLow; final displayNo = noStockCount > 0 ? noStockCount : fallbackNo; final previewItems = _filteredItems(); return ListView( controller: _scrollController, padding: AppTokens.pagePadding, children: [ // Hero card minimalista: solo 2 KPIs AppHeroCard( title: 'Inventario', subtitle: 'Stock y valor del almacén', child: Column( children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _HeroMetric( label: 'Stock', value: '${totals['stock'] ?? 0} uds', icon: Icons.inventory_2_rounded, ), ), const SizedBox(width: 12), Expanded( child: _HeroMetric( label: 'Valor', value: _formatCurrency(totals['stock_cost_value']), icon: Icons.account_balance_wallet_rounded, ), ), ], ), const SizedBox(height: 14), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () => _openMovementForm(warehouses: warehouses, transactions: transactions), icon: const Icon(Icons.add_rounded), label: const Text('Nuevo movimiento'), style: FilledButton.styleFrom( backgroundColor: Colors.white.withValues(alpha: 0.18), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), ), ), ), const SizedBox(height: 10), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => context.push('/stocktake'), icon: const Icon(Icons.fact_check_rounded), label: const Text('Inventario manual'), style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: BorderSide(color: Colors.white.withValues(alpha: 0.55)), padding: const EdgeInsets.symmetric(vertical: 12), ), ), ), ], ), ), // Chips de alerta — tap para filtrar la lista if (displayLow > 0 || displayNo > 0) ...[ const SizedBox(height: 14), Row( children: [ if (displayNo > 0) Expanded( child: _AlertChip( icon: Icons.remove_shopping_cart_rounded, label: '$displayNo agotados', color: AppTokens.danger, active: _activeFilter == _StockFilter.outOfStock, onTap: () => _toggleFilter(_StockFilter.outOfStock), ), ), if (displayNo > 0 && displayLow > 0) const SizedBox(width: 10), if (displayLow > 0) Expanded( child: _AlertChip( icon: Icons.warning_amber_rounded, label: '$displayLow bajo stock', color: AppTokens.warning, active: _activeFilter == _StockFilter.lowStock, onTap: () => _toggleFilter(_StockFilter.lowStock), ), ), ], ), if (_activeFilter != _StockFilter.all) ...[ const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: () => _toggleFilter(_activeFilter), icon: const Icon(Icons.close_rounded, size: 16), label: const Text('Quitar filtro'), ), ), ], ] else ...[ const SizedBox(height: 14), const _AlertChip( icon: Icons.check_circle_rounded, label: 'Inventario saludable', color: AppTokens.success, ), ], const SizedBox(height: 20), // Lista de productos AppPanel( title: _activeFilter == _StockFilter.outOfStock ? 'Productos agotados' : _activeFilter == _StockFilter.lowStock ? 'Productos bajo stock' : 'Productos', subtitle: 'Toca un producto para ver su detalle.', trailing: Text( '${previewItems.length}', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w700, ), ), child: previewItems.isEmpty ? EmptyStatePanel( icon: Icons.inventory_2_rounded, title: _activeFilter != _StockFilter.all ? 'Sin productos en este filtro' : 'Sin inventario visible', subtitle: _activeFilter != _StockFilter.all ? 'Quita el filtro para ver todos los productos.' : 'No se encontraron productos para el almacén del usuario.', ) : Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final item in previewItems) ...[ _InventoryItemRow( item: item, onMove: () => _openMovementForm( warehouses: warehouses, transactions: transactions, preSelectedItem: item, ), onDetail: () => _showItemDetailSheet(item), ), if (item != previewItems.last) const Divider(height: 20), ], ], ), ), // Botón "Cargar más" (solo si no hay filtro activo y hay más páginas) if (_activeFilter == _StockFilter.all && _hasMore) ...[ const SizedBox(height: 14), Center( child: OutlinedButton.icon( onPressed: _isLoadingMore ? null : _loadMore, icon: _isLoadingMore ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.expand_more_rounded), label: Text(_isLoadingMore ? 'Cargando...' : 'Cargar más productos'), ), ), ], if (_activeFilter == _StockFilter.all && !_hasMore && _accumulatedItems.isNotEmpty) ...[ const SizedBox(height: 14), Center( child: Text( 'No hay más productos', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), ), ], ], ); }, ), ), ); } double _toDouble(Object? value) => double.tryParse('$value') ?? 0; } class _InventoryBundle { const _InventoryBundle({ required this.stock, required this.options, }); final MobileApiResponse>> stock; final MobileApiResponse> options; } class _InventoryItemRow extends StatelessWidget { const _InventoryItemRow({ required this.item, required this.onMove, required this.onDetail, }); final Map item; final VoidCallback onMove; final VoidCallback onDetail; @override Widget build(BuildContext context) { final stock = double.tryParse('${item['stock']}') ?? 0; final stockMin = double.tryParse('${item['stock_min']}') ?? 0; final riskColor = stock <= 0 ? AppTokens.danger : stock <= stockMin ? AppTokens.warning : AppTokens.success; return InkWell( onTap: onDetail, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ CircleAvatar( backgroundColor: riskColor.withValues(alpha: 0.12), foregroundColor: riskColor, child: const Icon(Icons.medication_liquid_rounded, size: 18), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item['description']?.toString() ?? 'Producto', maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 4), Row( children: [ Icon(Icons.inventory_2_outlined, size: 13, color: riskColor), const SizedBox(width: 4), Text( 'Stock ${item['stock'] ?? 0}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: riskColor, fontWeight: FontWeight.w600, ), ), Text( ' · Min ${item['stock_min'] ?? 0}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), ], ), ], ), ), const SizedBox(width: 8), FilledButton.tonalIcon( onPressed: onMove, icon: const Icon(Icons.swap_horiz_rounded, size: 18), label: const Text('Mover'), style: FilledButton.styleFrom( backgroundColor: AppTokens.primary.withValues(alpha: 0.12), foregroundColor: AppTokens.primary, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), minimumSize: const Size(0, 36), ), ), ], ), ), ); } } class _HeroMetric extends StatelessWidget { const _HeroMetric({ required this.label, required this.value, required this.icon, }); final String label; final String value; final IconData icon; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppTokens.radiusMedium), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 18, color: Colors.white), const SizedBox(height: 10), Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.white.withValues(alpha: 0.84), ), ), ], ), ); } } class _AlertChip extends StatelessWidget { const _AlertChip({ required this.icon, required this.label, required this.color, this.onTap, this.active = false, }); final IconData icon; final String label; final Color color; final VoidCallback? onTap; final bool active; @override Widget build(BuildContext context) { final bgAlpha = active ? 0.22 : 0.10; final borderAlpha = active ? 0.60 : 0.30; return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: color.withValues(alpha: bgAlpha), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all( color: color.withValues(alpha: borderAlpha), width: active ? 1.5 : 1, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( active ? Icons.filter_alt_rounded : icon, size: 18, color: color, ), const SizedBox(width: 8), Flexible( child: Text( label, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: color, fontWeight: FontWeight.w700, ), ), ), ], ), ), ), ); } } class _DetailRow extends StatelessWidget { const _DetailRow({ required this.icon, required this.label, required this.value, this.color, }); final IconData icon; final String label; final String value; final Color? color; @override Widget build(BuildContext context) { final effectiveColor = color ?? AppTokens.textPrimary; return Row( children: [ Icon(icon, size: 20, color: effectiveColor), const SizedBox(width: 12), Expanded( child: Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.secondary, ), ), ), Text( value, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: effectiveColor, fontWeight: FontWeight.w700, ), ), ], ); } }