import 'dart:async'; 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:sysfarma_mobile/src/core/widgets/paginated_list_mixin.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; class CatalogScreen extends ConsumerStatefulWidget { const CatalogScreen({ super.key, this.title = 'Catalogo', this.subtitle = 'Busqueda por nombre, codigo o barras.', this.kind = 'product', this.allowCreate = true, this.isService = false, }); final String title; final String subtitle; final String kind; final bool allowCreate; final bool isService; @override ConsumerState createState() => _CatalogScreenState(); } class _CatalogScreenState extends ConsumerState with PaginatedListMixin { static String _formatStock(Object? value) { final n = double.tryParse('$value') ?? 0.0; return n == n.truncateToDouble() ? n.toInt().toString() : n.toStringAsFixed(1); } final _searchController = TextEditingController(); final _scrollController = ScrollController(); Timer? _searchDebounce; bool _loading = true; String? _error; late String _kind; String _filter = 'all'; // all | low | out | favorite @override void initState() { super.initState(); initPagination(_scrollController); onLoadPage = _fetchPage; _kind = widget.kind; _loadInitial(); } @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); _scrollController.dispose(); disposePagination(); super.dispose(); } void _onSearchChanged(String value) { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 350), () { if (mounted) _loadInitial(); }); } Future>>> _fetchPage(int page) { return ref.read(apiClientProvider).items( page: page, perPage: 12, context: 'inventory', search: _searchController.text.trim(), kind: _kind, ); } Future _loadInitial() async { setState(() { _loading = true; _error = null; }); final response = await _fetchPage(1); if (!mounted) { return; } if (_handleAuthFailure(response)) { return; } if (!response.success) { setState(() { _loading = false; _error = response.message ?? 'No se pudo cargar el catálogo.'; }); return; } setState(() { _loading = false; _error = null; resetPagination( firstPageItems: response.data ?? const [], firstPageMeta: response.meta, ); }); } bool _handleAuthFailure(MobileApiResponse response) { if (response.errors['auth'] == true && mounted) { context.go('/login'); return true; } return false; } Future _scanBarcode() async { final value = await context.push('/scanner'); if (value == null || value.isEmpty) { return; } _searchController.text = value; await _loadInitial(); } Future _scanBarcodeForEditor( TextEditingController controller, ) async { final value = await context.push('/scanner'); if (value == null || value.isEmpty) { return; } controller.text = value; } Future _showEditor(Map item) async { final isService = (item['unit_type_id']?.toString() ?? '') == 'ZZ'; final optionsResponse = await ref.read(apiClientProvider).itemOptions(); if (!mounted) return; final fallbackUnitTypes = [ {'id': 'NIU', 'description': 'Unidad', 'symbol': 'NIU'}, {'id': 'BL', 'description': 'Blister', 'symbol': 'BL'}, {'id': 'BX', 'description': 'Caja', 'symbol': 'BX'}, ]; final unitTypes = List>.from( optionsResponse.success ? (optionsResponse.data?['unit_types'] as List? ?? const []) : fallbackUnitTypes, ); final nameController = TextEditingController(text: item['description']?.toString() ?? ''); final internalIdController = TextEditingController(text: item['internal_id']?.toString() ?? ''); final barcodeController = TextEditingController(text: item['barcode']?.toString() ?? ''); final priceController = TextEditingController(text: '${item['sale_unit_price'] ?? ''}'); final stockMinController = TextEditingController(text: '${item['stock_min'] ?? ''}'); final formKey = GlobalKey(); var active = item['active'] != false; var favorite = item['favorite'] == true; var activeTab = 0; String? errorText; var saving = false; final existingPresentations = List>.from( (item['item_unit_types'] as List?) ?? const [], ); final presentationRows = <_PresentationRow>[]; final presentationIds = []; for (final raw in existingPresentations) { final row = Map.from(raw as Map); presentationRows.add(_PresentationRow( description: TextEditingController( text: row['description']?.toString() ?? ''), quantity: TextEditingController(text: '${row['quantity_unit'] ?? '1'}'), price: TextEditingController(text: '${row['price1'] ?? ''}'), barcode: TextEditingController(text: row['barcode']?.toString() ?? ''), unitTypeId: row['unit_type_id']?.toString(), )); presentationIds.add(int.tryParse('${row['id'] ?? ''}')); } await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { return StatefulBuilder( builder: (context, setSheetState) { Future submit() async { if (!(formKey.currentState?.validate() ?? false) || saving) { return; } setSheetState(() { saving = true; errorText = null; }); final salePrice = double.tryParse(priceController.text.trim()) ?? 0; final presentationsPayload = >[]; for (var i = 0; i < presentationRows.length; i++) { final row = presentationRows[i]; if (row.unitTypeId == null || row.quantity.text.trim().isEmpty) { continue; } presentationsPayload.add({ if (i < presentationIds.length && presentationIds[i] != null) 'id': presentationIds[i], 'description': row.description.text.trim().isEmpty ? (row.unitTypeId ?? '') : row.description.text.trim(), 'unit_type_id': row.unitTypeId, 'quantity_unit': double.tryParse(row.quantity.text.trim()) ?? 1, 'price1': double.tryParse(row.price.text.trim()) ?? salePrice, if (row.barcode.text.trim().isNotEmpty) 'barcode': row.barcode.text.trim(), }); } final response = await ref.read(apiClientProvider).updateItemBasic( id: int.tryParse('${item['id']}') ?? 0, payload: { 'description': nameController.text.trim(), 'internal_id': internalIdController.text.trim().isEmpty ? null : internalIdController.text.trim(), 'barcode': barcodeController.text.trim().isEmpty ? null : barcodeController.text.trim(), 'sale_unit_price': salePrice, 'stock_min': double.tryParse(stockMinController.text.trim()) ?? 0, 'active': active, 'favorite': favorite, if (!isService) 'item_unit_types': presentationsPayload, }, ); if (!context.mounted) { return; } if (_handleAuthFailure(response)) { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } return; } if (!response.success) { setSheetState(() { saving = false; errorText = response.message ?? 'No se pudo actualizar.'; }); return; } ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Cambios guardados.')), ); if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } await _loadInitial(); } return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 12, 20, 24 + MediaQuery.of(context).viewInsets.bottom, ), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isService ? 'Editar servicio' : 'Editar producto', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 14), if (!isService) _EditorTabBar( tabs: const ['General', 'Presentaciones'], activeIndex: activeTab, onChanged: (i) => setSheetState(() => activeTab = i), ), const SizedBox(height: 14), if (isService || activeTab == 0) ...[ TextFormField( controller: nameController, textInputAction: TextInputAction.next, decoration: const InputDecoration( labelText: 'Nombre', prefixIcon: Icon(Icons.badge_rounded), ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'Ingresa el nombre del producto.'; } if (value.trim().length > 500) { return 'El nombre es muy largo.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: internalIdController, textInputAction: TextInputAction.next, decoration: const InputDecoration( labelText: 'Codigo interno', prefixIcon: Icon(Icons.tag_rounded), ), validator: (value) { if (value != null && value.trim().length > 30) { return 'El codigo interno es muy largo.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: barcodeController, textInputAction: TextInputAction.next, decoration: InputDecoration( labelText: 'Codigo de barras', prefixIcon: const Icon(Icons.qr_code_rounded), suffixIcon: IconButton( onPressed: () => _scanBarcodeForEditor(barcodeController), icon: const Icon(Icons.qr_code_scanner_rounded), tooltip: 'Escanear codigo', ), ), validator: (value) { if (value != null && value.trim().length > 150) { return 'El codigo de barras es muy largo.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: priceController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Precio de venta', prefixIcon: Icon(Icons.sell_rounded), ), validator: (value) { final parsed = double.tryParse(value?.trim() ?? ''); if (parsed == null || parsed <= 0) { return 'Ingresa un precio mayor a 0.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: stockMinController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Stock mínimo', prefixIcon: Icon(Icons.inventory_rounded), ), validator: (value) { if (double.tryParse(value?.trim() ?? '') == null) { return 'Ingresa un stock mínimo válido.'; } return null; }, ), const SizedBox(height: 10), SwitchListTile.adaptive( value: active, title: const Text('Activo'), contentPadding: EdgeInsets.zero, onChanged: (value) => setSheetState(() => active = value), ), SwitchListTile.adaptive( value: favorite, title: const Text('Favorito'), contentPadding: EdgeInsets.zero, onChanged: (value) => setSheetState(() => favorite = value), ), ], if (!isService && activeTab == 1) ...[ if (presentationRows.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text( 'Sin presentaciones registradas.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), ), for (var i = 0; i < presentationRows.length; i++) ...[ _PresentationEditorRow( row: presentationRows[i], unitTypes: unitTypes, onRemove: () => setSheetState(() { presentationRows.removeAt(i); if (i < presentationIds.length) { presentationIds.removeAt(i); } }), onUnitTypeChanged: (value) { setSheetState(() { presentationRows[i] = presentationRows[i].copyWith( unitTypeId: value, ); }); }, ), const SizedBox(height: 12), ], OutlinedButton.icon( onPressed: () { setSheetState(() { presentationRows.add( _PresentationRow( description: TextEditingController( text: _unitTypeLabel( unitTypes, unitTypes.isNotEmpty ? unitTypes.first['id'] ?.toString() : 'NIU')), quantity: TextEditingController(text: '1'), price: TextEditingController(), barcode: TextEditingController(), unitTypeId: unitTypes.isNotEmpty ? unitTypes.first['id']?.toString() : 'NIU', ), ); presentationIds.add(null); }); }, icon: const Icon(Icons.add_rounded), label: const Text('Agregar presentacion'), ), ], if (errorText != null) ...[ const SizedBox(height: 10), Text( errorText!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.danger, ), ), ], const SizedBox(height: 18), FilledButton.icon( onPressed: saving ? null : submit, icon: saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.save_rounded), label: Text(saving ? 'Guardando...' : 'Guardar cambios'), ), ], ), ), ), ); }, ); }, ); } Future _showCreateItem({required bool isService}) async { final optionsResponse = await ref.read(apiClientProvider).itemOptions(); if (!mounted) { return; } if (_handleAuthFailure(optionsResponse)) { return; } final fallbackUnitTypes = [ {'id': 'NIU', 'description': 'Unidad', 'symbol': 'NIU'}, {'id': 'BL', 'description': 'Blister', 'symbol': 'BL'}, {'id': 'BX', 'description': 'Caja', 'symbol': 'BX'}, ]; if (!optionsResponse.success) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(optionsResponse.message ?? 'Opciones no disponibles.'), ), ); } final unitTypes = List>.from( optionsResponse.success ? (optionsResponse.data?['unit_types'] as List? ?? const []) : fallbackUnitTypes, ); final nameController = TextEditingController(); final internalIdController = TextEditingController(); final barcodeController = TextEditingController(); final salePriceController = TextEditingController(); final purchasePriceController = TextEditingController(); final stockController = TextEditingController(text: '0'); final stockMinController = TextEditingController(text: '0'); final formKey = GlobalKey(); var activeTab = 0; final presentationRows = <_PresentationRow>[ if (!isService) _PresentationRow( description: TextEditingController(text: 'Unidad'), quantity: TextEditingController(text: '1'), price: TextEditingController(), barcode: TextEditingController(), unitTypeId: unitTypes.isNotEmpty ? unitTypes.first['id']?.toString() : 'NIU', ), ]; String? errorText; var saving = false; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) { return StatefulBuilder( builder: (context, setSheetState) { Future submit() async { if (!(formKey.currentState?.validate() ?? false) || saving) { return; } setSheetState(() { saving = true; errorText = null; }); final salePrice = double.tryParse(salePriceController.text.trim()) ?? 0; final purchasePrice = double.tryParse(purchasePriceController.text.trim()); final stock = double.tryParse(stockController.text.trim()) ?? 0; final stockMin = double.tryParse(stockMinController.text.trim()) ?? 0; final presentations = presentationRows .where((row) => row.description.text.trim().isNotEmpty && row.unitTypeId != null && row.quantity.text.trim().isNotEmpty) .map((row) { return { 'description': row.description.text.trim(), 'unit_type_id': row.unitTypeId, 'quantity_unit': double.tryParse(row.quantity.text.trim()) ?? 1, 'price1': double.tryParse(row.price.text.trim()) ?? salePrice, 'price2': 0, 'price3': 0, 'price_default': 1, if (row.barcode.text.trim().isNotEmpty) 'barcode': row.barcode.text.trim(), }; }).toList(); final response = await ref.read(apiClientProvider).createItem( payload: { 'description': nameController.text.trim(), 'internal_id': internalIdController.text.trim().isEmpty ? null : internalIdController.text.trim(), 'barcode': barcodeController.text.trim().isEmpty ? null : barcodeController.text.trim(), 'unit_type_id': isService ? 'ZZ' : 'NIU', 'currency_type_id': 'PEN', 'sale_unit_price': salePrice, 'purchase_unit_price': purchasePrice ?? salePrice, 'stock': isService ? 0 : stock, 'stock_min': isService ? 0 : stockMin, 'sale_affectation_igv_type_id': '10', 'purchase_affectation_igv_type_id': '10', if (presentations.isNotEmpty) 'item_unit_types': presentations, }, ); if (!context.mounted) { return; } if (_handleAuthFailure(response)) { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } return; } if (!response.success) { setSheetState(() { saving = false; errorText = response.message ?? 'No se pudo crear.'; }); return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( isService ? 'Servicio creado.' : 'Producto creado.', ), ), ); if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } await _loadInitial(); } return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 12, 20, 24 + MediaQuery.of(context).viewInsets.bottom, ), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isService ? 'Nuevo servicio' : 'Nuevo producto', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 14), if (!isService) _EditorTabBar( tabs: const ['General', 'Presentaciones'], activeIndex: activeTab, onChanged: (i) => setSheetState(() => activeTab = i), ), const SizedBox(height: 14), if (isService || activeTab == 0) ...[ TextFormField( controller: nameController, textInputAction: TextInputAction.next, decoration: const InputDecoration( labelText: 'Nombre', prefixIcon: Icon(Icons.badge_rounded), ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'Ingresa el nombre.'; } if (value.trim().length > 500) { return 'El nombre es muy largo.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: internalIdController, textInputAction: TextInputAction.next, decoration: const InputDecoration( labelText: 'Codigo interno', prefixIcon: Icon(Icons.tag_rounded), ), validator: (value) { if (value != null && value.trim().length > 30) { return 'El codigo interno es muy largo.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: barcodeController, textInputAction: TextInputAction.next, decoration: InputDecoration( labelText: 'Codigo de barras', prefixIcon: const Icon(Icons.qr_code_rounded), suffixIcon: IconButton( onPressed: () => _scanBarcodeForEditor(barcodeController), icon: const Icon(Icons.qr_code_scanner_rounded), tooltip: 'Escanear codigo', ), ), validator: (value) { if (value != null && value.trim().length > 150) { return 'El codigo de barras es muy largo.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: salePriceController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Precio de venta', prefixIcon: Icon(Icons.sell_rounded), ), validator: (value) { final parsed = double.tryParse(value?.trim() ?? ''); if (parsed == null || parsed <= 0) { return 'Ingresa un precio mayor a 0.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: purchasePriceController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Costo de compra', prefixIcon: Icon(Icons.shopping_bag_rounded), ), ), if (!isService) ...[ const SizedBox(height: 14), TextFormField( controller: stockController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Stock inicial', prefixIcon: Icon(Icons.inventory_rounded), ), validator: (value) { if (double.tryParse(value?.trim() ?? '') == null) { return 'Ingresa un stock valido.'; } return null; }, ), const SizedBox(height: 14), TextFormField( controller: stockMinController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Stock minimo', prefixIcon: Icon(Icons.inventory_rounded), ), validator: (value) { if (double.tryParse(value?.trim() ?? '') == null) { return 'Ingresa un stock minimo valido.'; } return null; }, ), ], ], if (!isService && activeTab == 1) ...[ for (var i = 0; i < presentationRows.length; i++) ...[ _PresentationEditorRow( row: presentationRows[i], unitTypes: unitTypes, onRemove: presentationRows.length > 1 ? () => setSheetState(() { presentationRows.removeAt(i); }) : null, onUnitTypeChanged: (value) { setSheetState(() { presentationRows[i] = presentationRows[i].copyWith( unitTypeId: value, ); }); }, ), const SizedBox(height: 12), ], OutlinedButton.icon( onPressed: () { setSheetState(() { presentationRows.add( _PresentationRow( description: TextEditingController( text: _unitTypeLabel(unitTypes, unitTypes.isNotEmpty ? unitTypes.first['id']?.toString() : 'NIU'), ), quantity: TextEditingController(text: '1'), price: TextEditingController(), barcode: TextEditingController(), unitTypeId: unitTypes.isNotEmpty ? unitTypes.first['id']?.toString() : 'NIU', ), ); }); }, icon: const Icon(Icons.add_rounded), label: const Text('Agregar presentacion'), ), ], if (errorText != null) ...[ const SizedBox(height: 10), Text( errorText!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.danger, ), ), ], const SizedBox(height: 18), FilledButton.icon( onPressed: saving ? null : submit, icon: saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.save_rounded), label: Text(saving ? 'Guardando...' : 'Crear'), ), ], ), ), ), ); }, ); }, ); } @override Widget build(BuildContext context) { final all = List>.from(paginatedItems); final records = _applyFilter(all); return SafeArea( child: Stack( children: [ RefreshIndicator( onRefresh: _loadInitial, child: ListView( controller: _scrollController, padding: AppTokens.pagePadding, children: [ // Buscador con scanner al costado (sin AppPanel envoltorio). Row( children: [ Expanded( child: TextField( controller: _searchController, textInputAction: TextInputAction.search, onChanged: _onSearchChanged, onSubmitted: (_) => _loadInitial(), decoration: InputDecoration( labelText: 'Buscar por nombre o código', prefixIcon: const Icon(Icons.search_rounded), suffixIcon: _searchController.text.isNotEmpty ? IconButton( onPressed: () { _searchController.clear(); _loadInitial(); }, icon: const Icon(Icons.clear_rounded), ) : null, ), ), ), const SizedBox(width: 8), IconButton.filledTonal( onPressed: _scanBarcode, icon: const Icon(Icons.qr_code_scanner_rounded), tooltip: 'Escanear código', ), ], ), const SizedBox(height: 12), // Filtros (solo para productos, no servicios). if (!widget.isService) SizedBox( height: 36, child: ListView( scrollDirection: Axis.horizontal, children: [ _FilterChipBtn( label: 'Todos', active: _filter == 'all', onTap: () => setState(() => _filter = 'all'), ), const SizedBox(width: 8), _FilterChipBtn( label: 'Sin stock', active: _filter == 'out', color: AppTokens.danger, onTap: () => setState(() => _filter = 'out'), ), const SizedBox(width: 8), _FilterChipBtn( label: 'Bajo stock', active: _filter == 'low', color: AppTokens.warning, onTap: () => setState(() => _filter = 'low'), ), const SizedBox(width: 8), _FilterChipBtn( label: 'Favoritos', active: _filter == 'favorite', onTap: () => setState(() => _filter = 'favorite'), ), ], ), ), if (!widget.isService) const SizedBox(height: 8), // Conteo if (!_loading && _error == null && records.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( '${records.length} ${widget.isService ? "servicio(s)" : "producto(s)"}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.secondary, ), ), ), if (_loading) const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: CircularProgressIndicator()), ) else if (_error != null) EmptyStatePanel( icon: Icons.cloud_off_rounded, title: 'No se pudo cargar', subtitle: _error!, ) else if (records.isEmpty) EmptyStatePanel( icon: Icons.inventory_2_outlined, title: 'Sin resultados', subtitle: _searchController.text.isNotEmpty ? 'No se encontraron coincidencias.' : 'Aún no hay registros con este filtro.', ) else ...records.map(_buildItemCard), buildLoadMoreIndicator(), const SizedBox(height: 80), ], ), ), if (widget.allowCreate) Positioned( right: 20, bottom: 92, child: FloatingActionButton.extended( onPressed: () => _showCreateItem(isService: widget.isService), icon: const Icon(Icons.add_rounded), label: Text(widget.isService ? 'Nuevo servicio' : 'Nuevo producto'), ), ), ], ), ); } List> _applyFilter(List> rows) { if (widget.isService || _filter == 'all') return rows; return rows.where((item) { final stock = double.tryParse('${item['stock'] ?? 0}') ?? 0; final stockMin = double.tryParse('${item['stock_min'] ?? 0}') ?? 0; switch (_filter) { case 'out': return stock <= 0; case 'low': return stock > 0 && stockMin > 0 && stock <= stockMin; case 'favorite': return item['favorite'] == true; default: return true; } }).toList(); } Widget _buildItemCard(Map item) { final isService = widget.isService || (item['unit_type_id']?.toString() ?? '') == 'ZZ'; final stock = double.tryParse('${item['stock'] ?? 0}') ?? 0; final stockMin = double.tryParse('${item['stock_min'] ?? 0}') ?? 0; final isOutStock = !isService && stock <= 0; final isLowStock = !isService && stock > 0 && stockMin > 0 && stock <= stockMin; final isFavorite = item['favorite'] == true; final imageUrl = item['image_url']?.toString(); final hasRealImage = imageUrl != null && imageUrl.isNotEmpty && !imageUrl.contains('imagen-no-disponible'); final unitTypes = List>.from( item['item_unit_types'] as List? ?? const []); final borderColor = isOutStock ? AppTokens.danger : isLowStock ? AppTokens.warning : AppTokens.border; final borderWidth = (isOutStock || isLowStock) ? 2.0 : 1.0; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Material( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), child: InkWell( borderRadius: BorderRadius.circular(AppTokens.radiusSmall), onTap: () => _showEditor(item), onLongPress: () => _showQuickActions(item), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all(color: borderColor, width: borderWidth), ), child: Row( children: [ // Avatar/imagen Container( width: 44, height: 44, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: (isService ? AppTokens.cta : AppTokens.primary) .withValues(alpha: 0.10), borderRadius: BorderRadius.circular(10), ), child: hasRealImage ? Image.network( imageUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Icon( isService ? Icons.design_services_rounded : Icons.medication_rounded, color: isService ? AppTokens.cta : AppTokens.primary, size: 22, ), ) : Icon( isService ? Icons.design_services_rounded : Icons.medication_rounded, color: isService ? AppTokens.cta : AppTokens.primary, size: 22, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( item['description']?.toString() ?? 'Sin descripción', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), ), if (isFavorite) const Icon(Icons.star_rounded, size: 16, color: AppTokens.warning), ], ), const SizedBox(height: 2), Row( children: [ if (!isService) ...[ Text( 'Stock ${_formatStock(item['stock'])}', style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: isOutStock ? AppTokens.danger : isLowStock ? AppTokens.warning : AppTokens.secondary, fontWeight: (isOutStock || isLowStock) ? FontWeight.w700 : FontWeight.w500, ), ), const SizedBox(width: 8), ], if (unitTypes.length > 1) Padding( padding: const EdgeInsets.only(right: 8), child: Text( '${unitTypes.length} pres.', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: AppTokens.secondary), ), ), if ((item['internal_id']?.toString() ?? '') .isNotEmpty) Expanded( child: Text( item['internal_id'].toString(), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: AppTokens.secondary), ), ), ], ), ], ), ), const SizedBox(width: 8), Text( 'S/ ${item['sale_unit_price'] ?? '0.00'}', style: Theme.of(context).textTheme.titleSmall?.copyWith( color: AppTokens.primary, fontWeight: FontWeight.w800, ), ), ], ), ), ), ), ); } Future _showQuickActions(Map item) async { await showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.edit_rounded), title: const Text('Editar'), onTap: () { Navigator.pop(ctx); _showEditor(item); }, ), ListTile( leading: Icon( item['favorite'] == true ? Icons.star_rounded : Icons.star_outline_rounded, color: AppTokens.warning, ), title: Text(item['favorite'] == true ? 'Quitar de favoritos' : 'Marcar favorito'), onTap: () async { Navigator.pop(ctx); await _toggleFavorite(item); }, ), ListTile( leading: const Icon(Icons.toggle_on_rounded), title: Text(item['active'] != false ? 'Desactivar' : 'Activar'), onTap: () async { Navigator.pop(ctx); await _toggleActive(item); }, ), ], ), ), ); } Future _toggleFavorite(Map item) async { final id = int.tryParse('${item['id']}') ?? 0; if (id <= 0) return; final response = await ref.read(apiClientProvider).updateItemBasic( id: id, payload: { 'description': item['description'], 'sale_unit_price': item['sale_unit_price'], 'favorite': !(item['favorite'] == true), }, ); if (!mounted) return; if (response.success) await _loadInitial(); } Future _toggleActive(Map item) async { final id = int.tryParse('${item['id']}') ?? 0; if (id <= 0) return; final response = await ref.read(apiClientProvider).updateItemBasic( id: id, payload: { 'description': item['description'], 'sale_unit_price': item['sale_unit_price'], 'active': item['active'] == false, }, ); if (!mounted) return; if (response.success) await _loadInitial(); } } String _unitTypeLabel(List> unitTypes, String? id) { if (id == null) return 'Unidad'; for (final row in unitTypes) { if (row['id']?.toString() == id) { return row['description']?.toString() ?? 'Unidad'; } } return 'Unidad'; } class _FilterChipBtn extends StatelessWidget { const _FilterChipBtn({ required this.label, required this.active, required this.onTap, this.color = AppTokens.primary, }); final String label; final bool active; final VoidCallback onTap; final Color color; @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(999), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: active ? color.withValues(alpha: 0.12) : AppTokens.surface, borderRadius: BorderRadius.circular(999), border: Border.all( color: active ? color : AppTokens.border, width: active ? 1.5 : 1, ), ), child: Text( label, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: active ? color : AppTokens.secondary, fontWeight: active ? FontWeight.w700 : FontWeight.w500, ), ), ), ), ); } } class _EditorTabBar extends StatelessWidget { const _EditorTabBar({ required this.tabs, required this.activeIndex, required this.onChanged, }); final List tabs; final int activeIndex; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: AppTokens.surface, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all(color: AppTokens.border), ), child: Row( children: [ for (var i = 0; i < tabs.length; i++) Expanded( child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(AppTokens.radiusSmall), onTap: () => onChanged(i), child: Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: i == activeIndex ? AppTokens.primary.withValues(alpha: 0.10) : Colors.transparent, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: Center( child: Text( tabs[i], style: Theme.of(context).textTheme.labelLarge?.copyWith( color: i == activeIndex ? AppTokens.primary : AppTokens.secondary, fontWeight: i == activeIndex ? FontWeight.w700 : FontWeight.w500, ), ), ), ), ), ), ), ], ), ); } } class _PresentationRow { _PresentationRow({ required this.description, required this.quantity, required this.price, required this.barcode, required this.unitTypeId, }); final TextEditingController description; final TextEditingController quantity; final TextEditingController price; final TextEditingController barcode; final String? unitTypeId; _PresentationRow copyWith({ TextEditingController? description, TextEditingController? quantity, TextEditingController? price, TextEditingController? barcode, String? unitTypeId, }) { return _PresentationRow( description: description ?? this.description, quantity: quantity ?? this.quantity, price: price ?? this.price, barcode: barcode ?? this.barcode, unitTypeId: unitTypeId ?? this.unitTypeId, ); } } class _PresentationEditorRow extends StatelessWidget { const _PresentationEditorRow({ required this.row, required this.unitTypes, this.onRemove, this.onUnitTypeChanged, }); final _PresentationRow row; final List> unitTypes; final VoidCallback? onRemove; final ValueChanged? onUnitTypeChanged; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTokens.radiusMedium), border: Border.all(color: AppTokens.border), ), child: Column( children: [ Row( children: [ Expanded( child: DropdownButtonFormField( initialValue: row.unitTypeId, decoration: const InputDecoration( labelText: 'Unidad', prefixIcon: Icon(Icons.straighten_rounded), ), items: unitTypes .map( (unit) => DropdownMenuItem( value: unit['id']?.toString(), child: Text(unit['description']?.toString() ?? '-'), ), ) .toList(), onChanged: onUnitTypeChanged, ), ), if (onRemove != null) ...[ const SizedBox(width: 8), IconButton( onPressed: onRemove, icon: const Icon(Icons.delete_outline_rounded), tooltip: 'Quitar', ), ], ], ), const SizedBox(height: 12), TextFormField( controller: row.description, textInputAction: TextInputAction.next, decoration: const InputDecoration( labelText: 'Descripcion', prefixIcon: Icon(Icons.badge_rounded), ), ), const SizedBox(height: 12), Row( children: [ Expanded( child: TextFormField( controller: row.quantity, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Factor', prefixIcon: Icon(Icons.calculate_rounded), ), ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: row.price, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Precio', prefixIcon: Icon(Icons.sell_rounded), ), ), ), ], ), const SizedBox(height: 12), TextFormField( controller: row.barcode, textInputAction: TextInputAction.done, decoration: const InputDecoration( labelText: 'Barcode presentacion', prefixIcon: Icon(Icons.qr_code_rounded), ), ), ], ), ); } }