import 'package:enter_farma_plus_mobile/src/app/widgets/app_panel.dart'; import 'package:enter_farma_plus_mobile/src/app/widgets/empty_state_panel.dart'; import 'package:enter_farma_plus_mobile/src/core/models/tenant_session.dart'; import 'package:enter_farma_plus_mobile/src/core/network/api_client.dart'; import 'package:enter_farma_plus_mobile/src/core/network/mobile_api_response.dart'; import 'package:enter_farma_plus_mobile/src/core/storage/tenant_session_store.dart'; import 'package:enter_farma_plus_mobile/src/core/theme/app_tokens.dart'; import 'package:enter_farma_plus_mobile/src/core/utils/pdf_helper.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @override ConsumerState createState() => _DashboardScreenState(); } class _DashboardScreenState extends ConsumerState with TickerProviderStateMixin { late Future<_DashboardBundle> _future; Future>>? _cashFuture; static const ScrollPhysics _scrollPhysics = AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ); late AnimationController _staggerCtrl; bool _hasAnimated = false; // Stagger intervals late Animation _a0, _a1, _a2, _a3, _a4, _a5; // Period selector _DashPeriod _period = _DashPeriod.today; @override void initState() { super.initState(); _staggerCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _a0 = _interval(0.00, 0.55); _a1 = _interval(0.12, 0.67); _a2 = _interval(0.24, 0.79); _a3 = _interval(0.36, 0.91); _a4 = _interval(0.48, 1.00); _a5 = _interval(0.58, 1.00); _future = _load(); _cashFuture = _loadCashStatus(); } Animation _interval(double begin, double end) => CurvedAnimation( parent: _staggerCtrl, curve: Interval(begin, end, curve: Curves.easeOutCubic), ); void _playStagger() { if (!mounted) return; _staggerCtrl ..reset() ..forward(); } @override void dispose() { _staggerCtrl.dispose(); super.dispose(); } Future<_DashboardBundle> _load() async { _log('_load() INICIO period=${_period.name}'); try { final store = ref.read(tenantSessionStoreProvider); final api = ref.read(apiClientProvider); final session = await store.readSession(); _log( '_load() session=${session != null ? "OK (${session.baseUrl})" : "NULL"}'); if (session == null) { return const _DashboardBundle( session: null, summary: null, inventory: null); } final periodParams = _period.toApiParams(); _log('_load() llamando dashboardSummary + inventoryReport...'); final results = await Future.wait([ api.dashboardSummary( period: periodParams['period']!, dateStart: periodParams['date_start'], dateEnd: periodParams['date_end'], monthStart: periodParams['month_start'], monthEnd: periodParams['month_end'], ), api.inventoryReport(), ]); final summary = results[0]; final inventory = results[1]; _log( '_load() summary.success=${summary.success} inventory.success=${inventory.success}'); return _DashboardBundle( session: session, summary: summary, inventory: inventory); } catch (e, stack) { _log('_load() ERROR: $e'); _log('_load() STACK: $stack'); return _DashboardBundle( session: null, summary: MobileApiResponse.failure(message: e.toString()), inventory: null, ); } } void _log(String message) { // ignore: avoid_print print('[EnterFarma] $message'); } Future _refresh({_DashPeriod? period}) async { _hasAnimated = false; if (period != null) _period = period; _cashFuture = _loadCashStatus(); final future = _load(); setState(() { _future = future; }); await future; } Future>> _loadCashStatus() async { _log('_loadCashStatus() INICIO'); try { final api = ref.read(apiClientProvider); final response = await api.cashStatus(); _log( '_loadCashStatus() success=${response.success} message=${response.message}'); if (response.data != null) { _log('_loadCashStatus() data.keys=${response.data!.keys.toList()}'); _log('_loadCashStatus() cash=${response.data!['cash']}'); } return response; } catch (e, stack) { _log('_loadCashStatus() ERROR: $e'); _log('_loadCashStatus() STACK: $stack'); return MobileApiResponse.failure(message: e.toString()); } } void _showMessage(String message, {bool isError = false}) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: isError ? AppTokens.danger : null, ), ); } Future _openCashSheet({Map? cash}) async { final isClosing = cash != null && cash.isNotEmpty; if (isClosing) { await _openCashCloseArqueoSheet(cash); return; } final beginningBalanceController = TextEditingController(); 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 completar la operacion de caja.'; }); return; } if (!context.mounted) return; if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } _showMessage(response.message ?? 'Caja aperturada.'); await _refresh(); } 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.savings_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: 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.lock_open_rounded), label: Text( isSubmitting ? 'Procesando...' : 'Aperturar caja'), ), ], ), ), ), ); }, ); }, ); } Future _openCashCloseArqueoSheet(Map cash) async { final cashController = TextEditingController(text: '0'); final transfersController = TextEditingController(text: '0'); final expected = double.tryParse('${cash['income'] ?? 0}') ?? 0; final cashId = int.tryParse('${cash['id']}') ?? 0; await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetCtx) { return StatefulBuilder( builder: (sheetCtx, setSheetState) { final cashValue = double.tryParse(cashController.text.trim()) ?? 0; final transfersValue = double.tryParse(transfersController.text.trim()) ?? 0; final counted = cashValue + transfersValue; final difference = double.parse((counted - expected).toStringAsFixed(2)); Color diffColor; String diffLabel; if (difference.abs() < 0.01) { diffColor = AppTokens.success; diffLabel = 'Cuadra'; } else if (difference < 0) { diffColor = AppTokens.danger; diffLabel = 'Faltante'; } else { diffColor = AppTokens.warning; diffLabel = 'Sobrante'; } return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 20, 16, 20, 24 + MediaQuery.of(sheetCtx).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ CircleAvatar( backgroundColor: AppTokens.danger.withValues(alpha: 0.12), foregroundColor: AppTokens.danger, child: const Icon(Icons.lock_rounded), ), const SizedBox(width: 12), Text( 'Cerrar caja', style: Theme.of(context).textTheme.titleLarge, ), ], ), const SizedBox(height: 18), TextField( controller: cashController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Efectivo (S/)', prefixIcon: Icon(Icons.payments_rounded), ), onTap: () { cashController.selection = TextSelection( baseOffset: 0, extentOffset: cashController.text.length, ); }, onChanged: (_) => setSheetState(() {}), ), const SizedBox(height: 12), TextField( controller: transfersController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration( labelText: 'Transferencias (S/)', prefixIcon: Icon(Icons.account_balance_rounded), ), onTap: () { transfersController.selection = TextSelection( baseOffset: 0, extentOffset: transfersController.text.length, ); }, onChanged: (_) => setSheetState(() {}), ), const SizedBox(height: 18), Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: diffColor.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppTokens.radiusMedium), border: Border.all( color: diffColor.withValues(alpha: 0.30)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _CashSummaryLine( label: 'Esperado', value: _formatNumber(expected)), const SizedBox(height: 6), _CashSummaryLine( label: 'Contado', value: _formatNumber(counted), bold: true), const SizedBox(height: 6), _CashSummaryLine( label: diffLabel, value: _formatNumber(difference), color: diffColor, bold: true), ], ), ), const SizedBox(height: 18), FilledButton.icon( onPressed: () async { final api = ref.read(apiClientProvider); final response = await api.closeCash( cashId: cashId, cash: cashValue, transfers: transfersValue, ); if (!sheetCtx.mounted) return; if (!response.success) { ScaffoldMessenger.of(sheetCtx).showSnackBar( SnackBar( content: Text(response.message ?? 'No se pudo cerrar la caja.'), backgroundColor: AppTokens.danger, ), ); return; } Navigator.of(sheetCtx).pop(); _showMessage(response.message ?? 'Caja cerrada.'); await _refresh(); }, icon: const Icon(Icons.lock_rounded), label: const Text('Cerrar caja'), ), ], ), ), ); }, ); }, ); } Future _showCashReportSheet(int cashId) async { if (cashId <= 0) { _showMessage('No se encontro una caja valida para consultar el reporte.', isError: true); return; } final response = await ref.read(apiClientProvider).cashReport(cashId: cashId); if (!response.success) { _showMessage(response.message ?? 'No se pudo obtener el reporte de caja.', isError: true); return; } final data = response.data ?? const {}; final reports = Map.from(data['reports'] as Map? ?? const {}); final cash = Map.from(data['cash'] as Map? ?? const {}); if (!mounted) return; await showModalBottomSheet( context: context, builder: (context) { return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Reportes de caja', style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 8), Text( 'Caja ${cash['opening'] ?? '-'}', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: AppTokens.secondary), ), const SizedBox(height: 18), _buildReportRow( icon: Icons.picture_as_pdf_rounded, label: 'General A4', url: reports['general_a4_url']?.toString() ?? '-', ), const Divider(height: 24), _buildReportRow( icon: Icons.receipt_long_rounded, label: 'Ticket', url: reports['general_ticket_url']?.toString() ?? '-', ), const Divider(height: 24), _buildReportRow( icon: Icons.inventory_2_rounded, label: 'Productos', url: reports['products_url']?.toString() ?? '-', ), const Divider(height: 24), _buildReportRow( icon: Icons.bar_chart_rounded, label: 'Resumen ingresos', url: reports['income_summary_url']?.toString() ?? '-', ), ], ), ), ); }, ); } Widget _buildReportRow({ required IconData icon, required String label, required String url, }) { final isTappable = url != '-' && url.startsWith('http'); final row = _InfoRow( icon: icon, label: label, value: isTappable ? 'Abrir PDF' : url); if (!isTappable) return row; return InkWell( onTap: () async { final apiClient = ref.read(apiClientProvider); await PdfHelper.openAuthenticatedPdf( context: context, apiClient: apiClient, url: url, title: label, ); }, borderRadius: BorderRadius.circular(8), child: row, ); } @override Widget build(BuildContext context) { return SafeArea( child: RefreshIndicator( onRefresh: _refresh, child: FutureBuilder<_DashboardBundle>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const _DashboardSkeleton(); } // Trigger stagger once when data first arrives if (!_hasAnimated) { _hasAnimated = true; WidgetsBinding.instance .addPostFrameCallback((_) => _playStagger()); } final bundle = snapshot.data; if (bundle == null || bundle.session == null) { return const _DashboardMessageView( title: 'Sin sesión', subtitle: 'Inicia sesión para cargar el resumen operativo de la empresa.', ); } final summary = bundle.summary; if (summary == null || !summary.success) { return ListView( padding: AppTokens.pagePadding, children: [ AppPanel( title: 'No se pudo cargar', subtitle: summary?.message ?? 'Verifica la conectividad y vuelve a intentar.', child: FilledButton.icon( onPressed: _refresh, icon: const Icon(Icons.refresh_rounded), label: const Text('Reintentar'), ), ), ], ); } final data = summary.data ?? const {}; _log('BUILD data.keys=${data.keys.toList()}'); final totals = _asMap(data['totals']); final notifications = _asMap(data['notifications']); final cash = _asMap(data['cash']); _log( 'BUILD cash.isEmpty=${cash.isEmpty} cash.keys=${cash.keys.toList()}'); final period = _asMap(data['period']); final graph = _asMap(data['graph']); final graphLabels = (graph['labels'] is List) ? (graph['labels'] as List) .map((e) => e?.toString() ?? '') .toList() : []; final graphSeries = _parseGraphSeries(graph); final notSent = _toInt(notifications['documents_not_sent']); final toRegularize = _toInt(notifications['documents_regularize_shipping']); final invData = bundle.inventory?.data ?? const {}; final invTotals = _asMap(invData['totals']); final lowStockCount = _toInt(invTotals['low_stock_count']); final noStockCount = _toInt(invTotals['no_stock_count']); final operations = _toInt(totals['operations_count']); final companyName = bundle.session?.companyName ?? 'SysFarma'; final dateLabel = period['date_start']?.toString() ?? ''; // Payment breakdown + top products (new API fields) final rawPayments = data['payment_breakdown']; final paymentBreakdown = (rawPayments is List) ? rawPayments .whereType() .map((e) => _PaymentSlice( name: e['name']?.toString() ?? 'Otro', amount: _toDouble(e['amount']), )) .where((s) => s.amount > 0) .toList() : <_PaymentSlice>[]; final rawTopProducts = data['top_products']; final topProducts = (rawTopProducts is List) ? rawTopProducts .whereType() .map((e) => _TopProduct( name: e['name']?.toString() ?? '', amount: _toDouble(e['amount']), qty: _toDouble(e['qty']), )) .toList() : <_TopProduct>[]; // Check if all graph values are zero (flat line = hide chart) final graphTotal = graphSeries.fold( 0, (sum, s) => sum + s.values.fold(0, (a, b) => a + b), ); final hasGraphData = graphLabels.isNotEmpty && graphSeries.isNotEmpty && graphTotal > 0; return ListView( physics: _scrollPhysics, padding: AppTokens.pagePadding, children: [ // ── Hero card ────────────────────────────────────────────── _FadeSlide( animation: _a0, child: _HeroCard( companyName: companyName, dateLabel: dateLabel, totalValue: _formatNumber(totals['total']), operations: operations, cashOpen: cash.isNotEmpty, ), ), const SizedBox(height: 10), // ── Period selector ──────────────────────────────────────── _FadeSlide( animation: _a0, child: _PeriodSelector( selected: _period, onChanged: (p) => _refresh(period: p), ), ), const SizedBox(height: 14), // ── KPI cards ────────────────────────────────────────────── _FadeSlide( animation: _a1, child: AppPanel( title: 'Indicadores', subtitle: 'Resumen operativo rapido', child: Column( children: [ Row( children: [ Expanded( child: _KpiCard( icon: Icons.receipt_long_rounded, label: 'Por enviar', value: '$notSent', color: notSent > 0 ? AppTokens.warning : AppTokens.success, ), ), const SizedBox(width: 10), Expanded( child: _KpiCard( icon: Icons.sync_problem_rounded, label: 'Regularizar', value: '$toRegularize', color: toRegularize > 0 ? AppTokens.warning : AppTokens.success, ), ), ], ), const SizedBox(height: 10), Row( children: [ Expanded( child: _KpiCard( icon: Icons.inventory_2_rounded, label: 'Stock bajo', value: '$lowStockCount', color: lowStockCount > 0 ? AppTokens.warning : AppTokens.success, ), ), const SizedBox(width: 10), Expanded( child: _KpiCard( icon: Icons.remove_shopping_cart_rounded, label: 'Sin stock', value: '$noStockCount', color: noStockCount > 0 ? AppTokens.danger : AppTokens.success, ), ), ], ), ], ), ), // close AppPanel ), // close _FadeSlide KPI const SizedBox(height: 12), // ── Quick actions ────────────────────────────────────────── _FadeSlide( animation: _a2, child: _QuickActionGrid( isSeller: bundle.session?.isSeller ?? false, cashLabel: cash.isNotEmpty ? 'Cerrar caja' : 'Abrir caja', onSell: () => context.go('/sales'), onHistory: () => context.go('/sales-history'), onInventory: () => context.go('/inventory'), onPendingSync: () => context.go('/pending-sync'), onProfile: () => context.go('/profile'), onCash: () => _openCashSheet(cash: cash.isNotEmpty ? cash : null), ), ), const SizedBox(height: 12), // ── Banner comprobantes (sólo SUNAT, no stock — eso ya está en KPI) ── if (notSent > 0 || toRegularize > 0) _FadeSlide( animation: _a3, child: Padding( padding: const EdgeInsets.only(bottom: 12), child: _AlertBanner( icon: Icons.warning_amber_rounded, color: AppTokens.warning, title: '${notSent + toRegularize} comprobante(s) requieren atención SUNAT', subtitle: [ if (notSent > 0) '$notSent por enviar', if (toRegularize > 0) '$toRegularize por regularizar', ].join(' · '), onTap: () => context.go('/sales-history'), ), ), ), // ── Caja ────────────────────────────────────────────────── _FadeSlide( animation: _a4, child: _buildCashSection(cash), ), const SizedBox(height: 14), // ── Paid / Pending ───────────────────────────────────────── if (_toDouble(totals['total_paid']) > 0 || _toDouble(totals['total_pending']) > 0) _FadeSlide( animation: _a5, child: _PaidPendingRow( paid: _toDouble(totals['total_paid']), pending: _toDouble(totals['total_pending']), formatNumber: _formatNumber, ), ), if (_toDouble(totals['total_paid']) > 0 || _toDouble(totals['total_pending']) > 0) const SizedBox(height: 12), // ── Area chart (solo si hay datos reales) ───────────────── if (hasGraphData) _FadeSlide( animation: _a5, child: AppPanel( title: 'Tendencia de ventas', subtitle: _period.chartSubtitle, child: _SalesAreaChart( labels: graphLabels.take(12).toList(), series: graphSeries .map((s) => _ChartSeries( name: s.name, color: s.color, values: s.values.take(12).toList(), )) .toList(), formatValue: _formatNumber, ), ), ), if (hasGraphData) const SizedBox(height: 12), // ── Payment donut chart ──────────────────────────────────── if (paymentBreakdown.isNotEmpty) _FadeSlide( animation: _a5, child: AppPanel( title: 'Métodos de pago', subtitle: 'Distribución de cobros del período', child: _PaymentDonutChart( slices: paymentBreakdown, formatValue: _formatNumber, ), ), ), if (paymentBreakdown.isNotEmpty) const SizedBox(height: 12), // ── Top products bar chart ───────────────────────────────── if (topProducts.isNotEmpty) _FadeSlide( animation: _a5, child: AppPanel( title: 'Top productos', subtitle: 'Por monto vendido en el período', child: _TopProductsChart( products: topProducts, formatValue: _formatNumber, ), ), ), if (topProducts.isNotEmpty) const SizedBox(height: 12), SizedBox(height: MediaQuery.of(context).padding.bottom + 96), ], ); }, ), ), ); } static Map _asMap(Object? value) { if (value is Map) return Map.from(value); return const {}; } static int _toInt(Object? value) { if (value is int) return value; if (value is num) return value.toInt(); return int.tryParse(value?.toString() ?? '') ?? 0; } static double _toDouble(Object? value) { if (value is double) return value; if (value is num) return value.toDouble(); return double.tryParse(value?.toString() ?? '') ?? 0.0; } String _formatNumber(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'; } List<_ChartSeries> _parseGraphSeries(Map graph) { // Simple series format: { labels: [...], series: [n, n, n] } if (graph['series'] is List && graph['datasets'] == null) { final values = List.from( (graph['series'] as List).map((v) => double.tryParse('$v') ?? 0), ); return [ _ChartSeries(name: 'Ventas', values: values, color: AppTokens.primary) ]; } // Datasets format: { datasets: [{ name, data, color }] } final datasets = List>.from(graph['datasets'] as List? ?? const []); return datasets.map((dataset) { final values = List.from( (dataset['data'] as List? ?? const []) .map((value) => double.tryParse('$value') ?? 0), ); final colorValue = dataset['color']?.toString() ?? dataset['backgroundColor']?.toString(); return _ChartSeries( name: dataset['name']?.toString() ?? dataset['label']?.toString() ?? 'Serie', values: values, color: _parseColor(colorValue) ?? AppTokens.primary, ); }).toList(); } Color? _parseColor(String? raw) { if (raw == null || raw.isEmpty) { return null; } final normalized = raw.replaceAll('#', '').replaceAll('rgb(', '').replaceAll(')', ''); if (normalized.contains(',')) { final parts = normalized .split(',') .map((item) => int.tryParse(item.trim()) ?? 0) .toList(); if (parts.length == 3) { return Color.fromRGBO(parts[0], parts[1], parts[2], 1); } } if (normalized.length == 6) { return Color(int.parse('FF$normalized', radix: 16)); } return null; } Widget _buildCashSection(Map cash) { _log('_buildCashSection cash.isEmpty=${cash.isEmpty}'); if (cash.isNotEmpty) { _log('_buildCashSection usando cash del summary'); return _buildCashPanel(cash); } return FutureBuilder>>( future: _cashFuture, builder: (context, cashSnapshot) { _log( '_buildCashSection fallback state=${cashSnapshot.connectionState} hasData=${cashSnapshot.hasData}'); if (cashSnapshot.connectionState == ConnectionState.waiting) { return const AppPanel( title: 'Caja actual', subtitle: 'Consultando estado de caja...', child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: CircularProgressIndicator()), ), ); } final cashResponse = cashSnapshot.data; final cashData = _asMap(cashResponse?.data); final fallbackCash = _asMap(cashData['cash']); if (fallbackCash.isNotEmpty) { return _buildCashPanel(fallbackCash); } return AppPanel( title: 'Caja actual', subtitle: 'Estado del usuario autenticado dentro de la empresa.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const EmptyStatePanel( icon: Icons.point_of_sale_rounded, title: 'Sin caja aperturada', subtitle: 'Abre una caja para asociar documentos y controlar el cierre desde la app.', ), const SizedBox(height: 16), FilledButton.icon( onPressed: () => _openCashSheet(), icon: const Icon(Icons.lock_open_rounded), label: const Text('Aperturar caja'), ), ], ), ); }, ); } Widget _buildCashPanel(Map cash) { final beginning = double.tryParse('${cash['beginning_balance'] ?? 0}') ?? 0; final income = double.tryParse('${cash['income'] ?? 0}') ?? 0; final available = beginning + income; return AppPanel( title: 'Caja actual', child: Column( children: [ _InfoRow( icon: Icons.lock_open_rounded, label: 'Apertura', value: cash['opening']?.toString() ?? '-', ), const Divider(height: 26), _InfoRow( icon: Icons.payments_rounded, label: 'Ingresos', value: _formatNumber(cash['income']), ), const Divider(height: 26), _InfoRow( icon: Icons.account_balance_wallet_rounded, label: 'Disponible', value: _formatNumber(available), ), const SizedBox(height: 18), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: () => _showCashReportSheet(int.tryParse('${cash['id']}') ?? 0), icon: const Icon(Icons.description_rounded), label: const Text('Ver reporte'), ), ), const SizedBox(width: 12), Expanded( child: FilledButton.icon( onPressed: () => _openCashSheet(cash: cash), icon: const Icon(Icons.lock_rounded), label: const Text('Cerrar caja'), ), ), ], ), ], ), ); } } class _AlertBanner extends StatelessWidget { const _AlertBanner({ required this.icon, required this.color, required this.title, required this.subtitle, required this.onTap, }); final IconData icon; final Color color; final String title; final String subtitle; final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppTokens.radiusMedium), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: color.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppTokens.radiusMedium), border: Border.all(color: color.withValues(alpha: 0.30)), ), child: Row( children: [ Icon(icon, color: color, size: 22), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: Theme.of(context) .textTheme .titleSmall ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 2), Text( subtitle, style: Theme.of(context) .textTheme .bodySmall ?.copyWith(color: AppTokens.secondary), ), ], ), ), const Icon(Icons.chevron_right_rounded, color: AppTokens.secondary), ], ), ), ), ); } } class _KpiCard extends StatelessWidget { const _KpiCard({ required this.icon, required this.label, required this.value, required this.color, }); final IconData icon; final String label; final String value; final Color color; @override Widget build(BuildContext context) { final intValue = int.tryParse(value) ?? 0; return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), decoration: BoxDecoration( color: color.withValues(alpha: 0.09), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all(color: color.withValues(alpha: 0.22)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(9), ), child: Icon(icon, color: color, size: 17), ), const Spacer(), Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: intValue > 0 ? color : AppTokens.success, ), ), ], ), const SizedBox(height: 10), TweenAnimationBuilder( tween: Tween(begin: 0, end: intValue.toDouble()), duration: const Duration(milliseconds: 900), curve: Curves.easeOutCubic, builder: (context, animVal, _) { return Text( '${animVal.round()}', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w900, color: color, fontSize: 26, ), ); }, ), const SizedBox(height: 2), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: AppTokens.secondary, fontWeight: FontWeight.w500, ), ), ], ), ); } } class _QuickActionGrid extends StatelessWidget { const _QuickActionGrid({ required this.isSeller, required this.cashLabel, required this.onSell, required this.onHistory, required this.onInventory, required this.onPendingSync, required this.onProfile, required this.onCash, }); final bool isSeller; final String cashLabel; final VoidCallback onSell; final VoidCallback onHistory; final VoidCallback onInventory; final VoidCallback onPendingSync; final VoidCallback onProfile; final VoidCallback onCash; @override Widget build(BuildContext context) { final actions = isSeller ? [ ( Icons.point_of_sale_rounded, 'Vender', 'Registrar venta', AppTokens.primary, onSell ), ( Icons.sync_problem_rounded, 'Pendientes', 'Sincronizar envios', AppTokens.primaryDark, onPendingSync ), ( Icons.person_rounded, 'Mi perfil', 'Cuenta y seguridad', AppTokens.secondary, onProfile ), ( Icons.account_balance_wallet_rounded, cashLabel, 'Sesion de caja', AppTokens.cta, onCash ), ] : [ ( Icons.point_of_sale_rounded, 'Vender', 'Registrar venta', AppTokens.primary, onSell ), ( Icons.history_rounded, 'Historial', 'Comprobantes emitidos', AppTokens.primaryDark, onHistory ), ( Icons.inventory_2_rounded, 'Inventario', 'Stock y movimientos', AppTokens.secondary, onInventory ), ( Icons.account_balance_wallet_rounded, cashLabel, 'Sesion de caja', AppTokens.cta, onCash ), ]; return AppPanel( title: 'Acciones rápidas', child: GridView.count( crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), crossAxisSpacing: 10, mainAxisSpacing: 10, childAspectRatio: 2.4, children: actions.map((a) { final (icon, label, subtitle, color, onTap) = a; return _PressScaleButton( onTap: onTap, child: Container( decoration: BoxDecoration( color: color.withValues(alpha: 0.07), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all(color: color.withValues(alpha: 0.22)), boxShadow: [ BoxShadow( color: color.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 3), ), ], ), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), child: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( // Concentric radius: inner = outer - padding color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(11), ), child: Icon(icon, size: 19, color: color), ), const SizedBox(width: 10), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: AppTokens.textPrimary, fontWeight: FontWeight.w700, fontSize: 13, ), ), const SizedBox(height: 2), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppTokens.textSecondary, ), ), ], ), ), ], ), ), ); }).toList(), ), ); } } class _DashboardBundle { const _DashboardBundle({ required this.session, required this.summary, required this.inventory, }); final TenantSession? session; final MobileApiResponse>? summary; final MobileApiResponse>? inventory; } class _DashboardMessageView extends StatelessWidget { const _DashboardMessageView({ required this.title, required this.subtitle, }); final String title; final String subtitle; @override Widget build(BuildContext context) { return ListView( physics: const ClampingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), padding: AppTokens.pagePadding, children: [ AppPanel( title: title, subtitle: subtitle, child: const EmptyStatePanel( icon: Icons.dashboard_customize_rounded, title: 'Sin datos operativos', subtitle: 'La app necesita una sesion valida y conectividad con la empresa para mostrar el panel.', ), ), ], ); } } class _InfoRow extends StatelessWidget { const _InfoRow({ required this.icon, required this.label, required this.value, }); final IconData icon; final String label; final String value; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ CircleAvatar( radius: 18, backgroundColor: AppTokens.primary.withValues(alpha: 0.12), foregroundColor: AppTokens.primary, child: Icon(icon, size: 18), ), const SizedBox(width: 12), Expanded(child: Text(label)), const SizedBox(width: 12), Expanded( child: Text( value, textAlign: TextAlign.end, maxLines: 3, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium, ), ), ], ); } } class _CashSummaryLine extends StatelessWidget { const _CashSummaryLine({ required this.label, required this.value, this.color, this.bold = false, }); final String label; final String value; final Color? color; final bool bold; @override Widget build(BuildContext context) { final textColor = color ?? AppTokens.textPrimary; final style = Theme.of(context).textTheme.bodyMedium?.copyWith( color: textColor, fontWeight: bold ? FontWeight.w800 : FontWeight.w500, ); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: style), Text('S/ $value', style: style), ], ); } } class _ChartSeries { const _ChartSeries({ required this.name, required this.values, required this.color, }); final String name; final List values; final Color color; } // ─── Paid / Pending Row ─────────────────────────────────────────────────────── class _PaidPendingRow extends StatelessWidget { const _PaidPendingRow({ required this.paid, required this.pending, required this.formatNumber, }); final double paid; final double pending; final String Function(dynamic) formatNumber; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: _AmountTile( label: 'Cobrado', amount: paid, formatNumber: formatNumber, color: const Color(0xFF10B981), icon: Icons.check_circle_rounded, ), ), const SizedBox(width: 12), Expanded( child: _AmountTile( label: 'Pendiente', amount: pending, formatNumber: formatNumber, color: const Color(0xFFF59E0B), icon: Icons.schedule_rounded, ), ), ], ); } } class _AmountTile extends StatelessWidget { const _AmountTile({ required this.label, required this.amount, required this.formatNumber, required this.color, required this.icon, }); final String label; final double amount; final String Function(dynamic) formatNumber; final Color color; final IconData icon; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(16), border: Border.all(color: color.withValues(alpha: 0.2)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, size: 16, color: color), ), const SizedBox(width: 8), Text( label, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: color, fontWeight: FontWeight.w700, ), ), ], ), const SizedBox(height: 8), TweenAnimationBuilder( tween: Tween(begin: 0, end: amount), duration: const Duration(milliseconds: 900), curve: Curves.easeOutCubic, builder: (context, val, _) => Text( 'S/ ${formatNumber(val)}', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: color, fontWeight: FontWeight.w800, fontSize: 18, ), ), ), ], ), ); } } // ─── Sales Area Chart ───────────────────────────────────────────────────────── class _SalesAreaChart extends StatefulWidget { const _SalesAreaChart({ required this.labels, required this.series, required this.formatValue, }); final List labels; final List<_ChartSeries> series; final String Function(dynamic) formatValue; @override State<_SalesAreaChart> createState() => _SalesAreaChartState(); } class _SalesAreaChartState extends State<_SalesAreaChart> { int? _touchedIndex; @override Widget build(BuildContext context) { if (widget.series.isEmpty || widget.series.first.values.isEmpty) { return const SizedBox( height: 160, child: Center(child: Text('Sin datos de tendencia')), ); } final primarySeries = widget.series.first; final maxY = primarySeries.values.reduce((a, b) => a > b ? a : b); final interval = maxY > 0 ? (maxY / 4).ceilToDouble() : 1.0; return SizedBox( height: 200, child: LineChart( LineChartData( minY: 0, maxY: maxY * 1.15, gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: interval, getDrawingHorizontalLine: (_) => FlLine( color: AppTokens.textSecondary.withValues(alpha: 0.1), strokeWidth: 1, ), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, interval: 1, getTitlesWidget: (value, meta) { final idx = value.toInt(); if (idx < 0 || idx >= widget.labels.length) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 6), child: Text( widget.labels[idx], style: const TextStyle( fontSize: 10, color: AppTokens.textSecondary, fontWeight: FontWeight.w500, ), ), ); }, ), ), ), lineTouchData: LineTouchData( touchCallback: (event, response) { setState(() { _touchedIndex = response?.lineBarSpots?.first.spotIndex; }); }, touchTooltipData: LineTouchTooltipData( getTooltipColor: (_) => AppTokens.primary, getTooltipItems: (spots) => spots.map((s) { return LineTooltipItem( 'S/ ${widget.formatValue(s.y)}', const TextStyle( color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12, ), ); }).toList(), ), ), lineBarsData: widget.series.map((series) { return LineChartBarData( spots: series.values.asMap().entries.map((e) { return FlSpot(e.key.toDouble(), e.value); }).toList(), isCurved: true, curveSmoothness: 0.35, color: series.color, barWidth: 2.5, dotData: FlDotData( show: true, getDotPainter: (spot, percent, barData, index) { final isTouched = index == _touchedIndex; return FlDotCirclePainter( radius: isTouched ? 5 : 3, color: series.color, strokeWidth: isTouched ? 2 : 0, strokeColor: Colors.white, ); }, ), belowBarData: BarAreaData( show: true, gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ series.color.withValues(alpha: 0.25), series.color.withValues(alpha: 0.0), ], ), ), ); }).toList(), ), duration: const Duration(milliseconds: 500), curve: Curves.easeOutCubic, ), ); } } // ─── Period ─────────────────────────────────────────────────────────────────── enum _DashPeriod { today, week, month } extension _DashPeriodX on _DashPeriod { String get label { switch (this) { case _DashPeriod.today: return 'Hoy'; case _DashPeriod.week: return 'Semana'; case _DashPeriod.month: return 'Mes'; } } String get chartSubtitle { switch (this) { case _DashPeriod.today: return 'Ingresos por hora'; case _DashPeriod.week: return 'Ingresos por día'; case _DashPeriod.month: return 'Ingresos por día del mes'; } } Map toApiParams() { final now = DateTime.now(); final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; final monthStr = '${now.year}-${now.month.toString().padLeft(2, '0')}'; switch (this) { case _DashPeriod.today: return { 'period': 'date', 'date_start': today, 'date_end': today, 'month_start': null, 'month_end': null, }; case _DashPeriod.week: final weekAgo = now.subtract(const Duration(days: 6)); final weekStart = '${weekAgo.year}-${weekAgo.month.toString().padLeft(2, '0')}-${weekAgo.day.toString().padLeft(2, '0')}'; return { 'period': 'between_dates', 'date_start': weekStart, 'date_end': today, 'month_start': null, 'month_end': null, }; case _DashPeriod.month: return { 'period': 'month', 'date_start': today, 'date_end': today, 'month_start': monthStr, 'month_end': monthStr, }; } } } // ─── _PeriodSelector ────────────────────────────────────────────────────────── class _PeriodSelector extends StatelessWidget { const _PeriodSelector({ required this.selected, required this.onChanged, }); final _DashPeriod selected; final void Function(_DashPeriod) onChanged; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( color: AppTokens.border.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), child: Row( children: _DashPeriod.values.map((p) { final active = p == selected; return Expanded( child: _PressScaleButton( onTap: () => onChanged(p), child: AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: active ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(AppTokens.radiusSmall - 3), boxShadow: active ? [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 6, offset: const Offset(0, 2), ) ] : null, ), child: Center( child: Text( p.label, style: TextStyle( fontSize: 13, fontWeight: active ? FontWeight.w700 : FontWeight.w500, color: active ? AppTokens.primary : AppTokens.textSecondary, ), ), ), ), ), ); }).toList(), ), ); } } // ─── Data models ───────────────────────────────────────────────────────────── class _PaymentSlice { const _PaymentSlice({required this.name, required this.amount}); final String name; final double amount; } class _TopProduct { const _TopProduct({ required this.name, required this.amount, required this.qty, }); final String name; final double amount; final double qty; } // ─── _FadeSlide ─────────────────────────────────────────────────────────────── // Wraps any widget in fade + slide-up transition driven by an Animation class _FadeSlide extends StatelessWidget { const _FadeSlide({required this.animation, required this.child}); final Animation animation; final Widget child; @override Widget build(BuildContext context) { return FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0, 0.18), end: Offset.zero, ).animate(animation), child: child, ), ); } } // ─── _HeroCard ──────────────────────────────────────────────────────────────── class _HeroCard extends StatelessWidget { const _HeroCard({ required this.companyName, required this.dateLabel, required this.totalValue, required this.operations, required this.cashOpen, }); final String companyName; final String dateLabel; final String totalValue; final int operations; final bool cashOpen; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(20, 20, 20, 22), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF00897B), Color(0xFF004D40)], stops: [0.0, 1.0], ), borderRadius: BorderRadius.circular(AppTokens.radiusMedium), boxShadow: [ BoxShadow( color: const Color(0xFF00796B).withValues(alpha: 0.45), blurRadius: 24, offset: const Offset(0, 8), ), ], ), child: Stack( children: [ // Decorative circles (background texture) Positioned( right: -20, top: -30, child: Container( width: 130, height: 130, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.06), ), ), ), Positioned( right: 30, bottom: -40, child: Container( width: 90, height: 90, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.04), ), ), ), // Content Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( companyName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: Colors.white.withValues(alpha: 0.85), fontWeight: FontWeight.w600, ), ), ), // Cash status pill Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: cashOpen ? Colors.white.withValues(alpha: 0.18) : Colors.white.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(999), border: Border.all( color: Colors.white.withValues(alpha: 0.25), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 7, height: 7, decoration: BoxDecoration( shape: BoxShape.circle, color: cashOpen ? const Color(0xFF69F0AE) : Colors.white.withValues(alpha: 0.5), ), ), const SizedBox(width: 5), Text( cashOpen ? 'Abierta' : 'Cerrada', style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600, ), ), ], ), ), ], ), const SizedBox(height: 16), Text( 'Ventas de hoy', style: TextStyle( color: Colors.white.withValues(alpha: 0.70), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.8, ), ), const SizedBox(height: 4), Text( totalValue, style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Colors.white, fontWeight: FontWeight.w800, letterSpacing: -0.5, ), ), const SizedBox(height: 12), Row( children: [ _HeroBadge( icon: Icons.receipt_rounded, label: '$operations operación${operations == 1 ? '' : 'es'}', ), if (dateLabel.isNotEmpty) ...[ const SizedBox(width: 8), _HeroBadge( icon: Icons.calendar_today_rounded, label: dateLabel, ), ], ], ), ], ), ], ), ); } } class _HeroBadge extends StatelessWidget { const _HeroBadge({required this.icon, required this.label}); final IconData icon; final String label; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 12, color: Colors.white.withValues(alpha: 0.80)), const SizedBox(width: 5), Text( label, style: TextStyle( color: Colors.white.withValues(alpha: 0.90), fontSize: 11, fontWeight: FontWeight.w500, ), ), ], ), ); } } // ─── _PressScaleButton ──────────────────────────────────────────────────────── // Scale-on-press wrapper (0.96 per design guidelines) class _PressScaleButton extends StatefulWidget { const _PressScaleButton({required this.onTap, required this.child}); final VoidCallback onTap; final Widget child; @override State<_PressScaleButton> createState() => _PressScaleButtonState(); } class _PressScaleButtonState extends State<_PressScaleButton> { bool _pressed = false; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) { setState(() => _pressed = false); widget.onTap(); }, onTapCancel: () => setState(() => _pressed = false), child: AnimatedScale( scale: _pressed ? 0.96 : 1.0, duration: const Duration(milliseconds: 120), curve: Curves.easeOutCubic, child: widget.child, ), ); } } // ─── _ShimmerBox ────────────────────────────────────────────────────────────── class _ShimmerBox extends StatefulWidget { const _ShimmerBox({ this.width = double.infinity, required this.height, this.radius = 12.0, }); final double width; final double height; final double radius; @override State<_ShimmerBox> createState() => _ShimmerBoxState(); } class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin { late AnimationController _ctrl; late Animation _anim; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), )..repeat(); _anim = Tween(begin: -2, end: 2).animate( CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut), ); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _anim, builder: (_, __) => Container( width: widget.width == double.infinity ? null : widget.width, height: widget.height, decoration: BoxDecoration( borderRadius: BorderRadius.circular(widget.radius), gradient: LinearGradient( begin: Alignment(_anim.value - 1, 0), end: Alignment(_anim.value + 1, 0), colors: const [ Color(0xFFEAEFF4), Color(0xFFF8FAFB), Color(0xFFEAEFF4), ], ), ), ), ); } } // ─── _DashboardSkeleton ─────────────────────────────────────────────────────── class _DashboardSkeleton extends StatelessWidget { const _DashboardSkeleton(); @override Widget build(BuildContext context) { return ListView( physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()), padding: AppTokens.pagePadding, children: [ // Hero skeleton const _ShimmerBox(height: 150, radius: AppTokens.radiusMedium), const SizedBox(height: 14), // KPI panel skeleton Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTokens.radiusMedium), border: Border.all(color: AppTokens.border), ), child: const Column( children: [ _ShimmerBox(height: 14, width: 120, radius: 6), SizedBox(height: 16), Row( children: [ Expanded(child: _ShimmerBox(height: 76, radius: 14)), SizedBox(width: 10), Expanded(child: _ShimmerBox(height: 76, radius: 14)), ], ), SizedBox(height: 10), Row( children: [ Expanded(child: _ShimmerBox(height: 76, radius: 14)), SizedBox(width: 10), Expanded(child: _ShimmerBox(height: 76, radius: 14)), ], ), ], ), ), const SizedBox(height: 12), // Quick actions skeleton const _ShimmerBox(height: 120, radius: AppTokens.radiusMedium), const SizedBox(height: 12), // Chart skeleton Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTokens.radiusMedium), border: Border.all(color: AppTokens.border), ), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ShimmerBox(height: 14, width: 140, radius: 6), SizedBox(height: 16), _ShimmerBox(height: 180, radius: 12), ], ), ), ], ); } } // ─── Chart color palette ────────────────────────────────────────────────────── const List _kChartColors = [ Color(0xFF00796B), // teal primary Color(0xFF3B82F6), // blue Color(0xFFF59E0B), // amber Color(0xFFEC4899), // pink Color(0xFF8B5CF6), // violet Color(0xFF10B981), // emerald ]; // ─── _PaymentDonutChart ─────────────────────────────────────────────────────── class _PaymentDonutChart extends StatefulWidget { const _PaymentDonutChart({ required this.slices, required this.formatValue, }); final List<_PaymentSlice> slices; final String Function(dynamic) formatValue; @override State<_PaymentDonutChart> createState() => _PaymentDonutChartState(); } class _PaymentDonutChartState extends State<_PaymentDonutChart> { int? _touched; @override Widget build(BuildContext context) { final total = widget.slices.fold(0, (s, e) => s + e.amount); return Column( children: [ SizedBox( height: 180, child: Row( children: [ Expanded( child: PieChart( PieChartData( pieTouchData: PieTouchData( touchCallback: (event, response) { setState(() { _touched = event is FlTapUpEvent ? null : response?.touchedSection?.touchedSectionIndex; }); }, ), sectionsSpace: 2, centerSpaceRadius: 44, sections: widget.slices.asMap().entries.map((entry) { final idx = entry.key; final slice = entry.value; final isTouched = _touched == idx; final pct = total > 0 ? slice.amount / total * 100 : 0; final color = _kChartColors[idx % _kChartColors.length]; return PieChartSectionData( value: slice.amount, color: color, radius: isTouched ? 56 : 48, title: '${pct.toStringAsFixed(0)}%', titleStyle: TextStyle( fontSize: isTouched ? 13 : 11, fontWeight: FontWeight.w700, color: Colors.white, ), titlePositionPercentageOffset: 0.65, ); }).toList(), ), duration: const Duration(milliseconds: 600), curve: Curves.easeOutCubic, ), ), const SizedBox(width: 12), // Legend Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: widget.slices.asMap().entries.map((entry) { final idx = entry.key; final slice = entry.value; final color = _kChartColors[idx % _kChartColors.length]; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, height: 10, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(3), ), ), const SizedBox(width: 6), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( slice.name, style: const TextStyle( fontSize: 11, color: AppTokens.textSecondary, fontWeight: FontWeight.w500, ), ), Text( widget.formatValue(slice.amount), style: const TextStyle( fontSize: 12, color: AppTokens.textPrimary, fontWeight: FontWeight.w700, ), ), ], ), ], ), ); }).toList(), ), ], ), ), // Total footer const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: AppTokens.primary.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Total cobrado', style: TextStyle( fontSize: 12, color: AppTokens.textSecondary, fontWeight: FontWeight.w600, ), ), Text( widget.formatValue(total), style: const TextStyle( fontSize: 14, color: AppTokens.primary, fontWeight: FontWeight.w800, ), ), ], ), ), ], ); } } // ─── _TopProductsChart ──────────────────────────────────────────────────────── class _TopProductsChart extends StatefulWidget { const _TopProductsChart({ required this.products, required this.formatValue, }); final List<_TopProduct> products; final String Function(dynamic) formatValue; @override State<_TopProductsChart> createState() => _TopProductsChartState(); } class _TopProductsChartState extends State<_TopProductsChart> with SingleTickerProviderStateMixin { late AnimationController _ctrl; late Animation _anim; int? _touched; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 700), )..forward(); _anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.products.isEmpty) return const SizedBox.shrink(); final maxAmount = widget.products.map((p) => p.amount).reduce((a, b) => a > b ? a : b); return AnimatedBuilder( animation: _anim, builder: (context, _) { return Column( children: widget.products.asMap().entries.map((entry) { final idx = entry.key; final product = entry.value; final pct = maxAmount > 0 ? (product.amount / maxAmount) : 0.0; final color = _kChartColors[idx % _kChartColors.length]; final isTouched = _touched == idx; return GestureDetector( onTapDown: (_) => setState(() => _touched = idx), onTapUp: (_) => setState(() => _touched = null), onTapCancel: () => setState(() => _touched = null), child: AnimatedContainer( duration: const Duration(milliseconds: 120), padding: EdgeInsets.all(isTouched ? 10 : 8), margin: const EdgeInsets.only(bottom: 10), decoration: BoxDecoration( color: isTouched ? color.withValues(alpha: 0.08) : Colors.transparent, borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( product.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppTokens.textPrimary, ), ), ), const SizedBox(width: 8), Text( widget.formatValue(product.amount), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: color, ), ), ], ), const SizedBox(height: 5), ClipRRect( borderRadius: BorderRadius.circular(999), child: Stack( children: [ // Track Container( height: 6, color: color.withValues(alpha: 0.12), ), // Fill — animated width FractionallySizedBox( widthFactor: (pct * _anim.value).clamp(0.0, 1.0), child: Container( height: 6, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(999), ), ), ), ], ), ), const SizedBox(height: 3), Text( '${product.qty % 1 == 0 ? product.qty.toInt() : product.qty.toStringAsFixed(1)} uds.', style: const TextStyle( fontSize: 10, color: AppTokens.textSecondary, ), ), ], ), ), ); }).toList(), ); }, ); } }