import 'package:sysfarma_mobile/src/core/models/tenant_session.dart'; import 'package:sysfarma_mobile/src/core/notifications/push_notification_service.dart'; import 'package:sysfarma_mobile/src/core/state/unsaved_work_provider.dart'; import 'package:sysfarma_mobile/src/core/storage/tenant_session_store.dart'; import 'package:sysfarma_mobile/src/core/theme/app_tokens.dart'; import 'package:sysfarma_mobile/src/core/widgets/connectivity_banner.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; class MobileShellScaffold extends ConsumerWidget { const MobileShellScaffold({ required this.child, super.key, }); final Widget child; /// Nav items: 4 tabs around the center button. /// Index mapping: 0=Inicio, 1=Inventario, [center=Vender], 2=Reportes, 3=Mas static const _tabs = <({String route, String label, IconData icon, IconData activeIcon})>[ ( route: '/home', label: 'Inicio', icon: Icons.home_outlined, activeIcon: Icons.home_rounded ), ( route: '/inventory', label: 'Inventario', icon: Icons.inventory_2_outlined, activeIcon: Icons.inventory_2_rounded ), ( route: '/reports', label: 'Reportes', icon: Icons.bar_chart_outlined, activeIcon: Icons.bar_chart_rounded ), ( route: '/more', label: 'Más', icon: Icons.grid_view_outlined, activeIcon: Icons.grid_view_rounded ), ]; /// Candidatos para llenar el slot de Inventario/Reportes cuando el usuario /// no tiene ese modulo, en vez de dejar un hueco vacio en la barra. Se usan /// en este orden, saltando el que ya este ocupado por el otro slot. static const _fallbackCandidates = <({ String route, String label, IconData icon, IconData activeIcon, String module, })>[ ( route: '/purchases', label: 'Compras', icon: Icons.shopping_bag_outlined, activeIcon: Icons.shopping_bag_rounded, module: 'purchases', ), ( route: '/sales-history', label: 'Historial', icon: Icons.history_rounded, activeIcon: Icons.history_rounded, module: 'documents', ), ( route: '/customers', label: 'Clientes', icon: Icons.people_alt_outlined, activeIcon: Icons.people_alt_rounded, module: 'persons', ), ( route: '/catalog', label: 'Productos', icon: Icons.inventory_2_outlined, activeIcon: Icons.inventory_2_rounded, module: 'items', ), ]; String _resolveLabel(String location) { if (location.startsWith('/sales')) return 'Vender'; if (location.startsWith('/catalog')) return 'Productos'; if (location.startsWith('/services')) return 'Servicios'; if (location.startsWith('/customers')) return 'Clientes'; if (location.startsWith('/suppliers')) return 'Proveedores'; if (location.startsWith('/purchases')) return 'Compras'; if (location.startsWith('/quick')) return 'Consulta SUNAT'; if (location.startsWith('/profile/password')) return 'Cambiar contraseña'; if (location.startsWith('/profile')) return 'Mi perfil'; if (location.startsWith('/more')) return 'Más opciones'; if (location.startsWith('/pending-voided')) return 'Por anular'; if (location.startsWith('/reports/general')) return 'General'; if (location.startsWith('/reports/critical-stock')) return 'Stock crítico'; if (location.startsWith('/reports/expiring-lots')) return 'Por vencer'; if (location.startsWith('/reports/by-seller')) return 'Por vendedor'; if (location.startsWith('/reports/inventory')) return 'Inventario'; if (location.startsWith('/reports/lots-overview')) return 'Lotes'; for (final tab in _tabs) { if (location.startsWith(tab.route)) return tab.label; } return 'Inicio'; } IconData _resolveIcon(String location) { if (location.startsWith('/sales')) return Icons.point_of_sale_rounded; if (location.startsWith('/catalog')) return Icons.inventory_2_rounded; if (location.startsWith('/services')) return Icons.design_services_rounded; if (location.startsWith('/customers')) return Icons.people_alt_rounded; if (location.startsWith('/suppliers')) return Icons.local_shipping_rounded; if (location.startsWith('/purchases')) return Icons.shopping_bag_rounded; if (location.startsWith('/quick')) return Icons.dashboard_customize_rounded; if (location.startsWith('/profile')) return Icons.person_rounded; for (final tab in _tabs) { if (location.startsWith(tab.route)) return tab.activeIcon; } return Icons.home_rounded; } ({String route, String label, IconData icon, IconData activeIcon})? _resolveSlot( ({String route, String label, IconData icon, IconData activeIcon}) preferred, bool preferredAllowed, Set usedRoutes, TenantSession? session, ) { if (preferredAllowed) { usedRoutes.add(preferred.route); return preferred; } if (session == null) return null; for (final candidate in _fallbackCandidates) { if (usedRoutes.contains(candidate.route)) continue; if (!session.hasModule(candidate.module)) continue; usedRoutes.add(candidate.route); return ( route: candidate.route, label: candidate.label, icon: candidate.icon, activeIcon: candidate.activeIcon, ); } return null; } @override Widget build(BuildContext context, WidgetRef ref) { final location = GoRouterState.of(context).matchedLocation; final isSalesActive = location.startsWith('/sales'); final currentLabel = _resolveLabel(location); final currentIcon = _resolveIcon(location); final textTheme = Theme.of(context).textTheme; final sessionAsync = ref.watch(currentSessionProvider); final session = sessionAsync.maybeWhen(data: (s) => s, orElse: () => null); final canViewInventory = session?.hasModule('inventory') ?? false; final canViewReports = session?.hasModule('reports') ?? false; // Si el usuario no tiene Inventario/Reportes, se intenta llenar ese slot // con otro modulo al que si tenga acceso (Compras/Historial/Clientes/ // Productos), para no dejar un hueco vacio en la barra. El slot derecho // se resuelve despues para no repetir lo que ya ocupo el izquierdo. final usedRoutes = {}; final leftSlot = _resolveSlot( _tabs[1], canViewInventory, usedRoutes, session, ); final rightSlot = _resolveSlot( _tabs[2], canViewReports, usedRoutes, session, ); final companyLabel = sessionAsync.maybeWhen( data: (s) => s?.companyName?.trim(), orElse: () => null, ); final isHome = location.startsWith('/home'); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { if (didPop) return; _handleBackPress(context, ref, isHome: isHome); }, child: Scaffold( extendBody: true, body: Stack( children: [ // Background const Positioned.fill( child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration(color: AppTokens.background), ), ), ), // Decorative circles Positioned( top: -48, right: -24, child: IgnorePointer( child: Container( width: 180, height: 180, decoration: BoxDecoration( color: AppTokens.primary.withValues(alpha: 0.08), shape: BoxShape.circle, ), ), ), ), Positioned( top: 220, left: -52, child: IgnorePointer( child: Container( width: 160, height: 160, decoration: BoxDecoration( color: AppTokens.cta.withValues(alpha: 0.06), shape: BoxShape.circle, ), ), ), ), // Content Positioned.fill( child: Column( children: [ const ConnectivityBanner(), // Top bar Container( margin: const EdgeInsets.symmetric(horizontal: 14), padding: EdgeInsets.only( top: MediaQuery.of(context).padding.top + 8, left: 14, right: 8, bottom: 10, ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTokens.radiusSmall), border: Border.all( color: AppTokens.border.withValues(alpha: 0.85), ), boxShadow: [ BoxShadow( color: AppTokens.textPrimary.withValues(alpha: 0.05), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Row( children: [ Icon(currentIcon, color: AppTokens.primary, size: 22), const SizedBox(width: 10), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( currentLabel, style: textTheme.titleMedium?.copyWith( color: AppTokens.textPrimary, ), ), if (companyLabel != null && companyLabel.isNotEmpty) Text( companyLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.bodySmall?.copyWith( color: AppTokens.textSecondary, ), ), ], ), ), PopupMenuButton( offset: const Offset(0, 48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppTokens.radiusSmall), ), onSelected: (value) async { if (value == 'logout') { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Cerrar sesion'), content: const Text('¿Seguro que deseas salir?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Salir'), ), ], ), ); if (confirmed == true) { await ref .read(pushNotificationServiceProvider) .revokeCurrentToken(); await ref.read(tenantSessionStoreProvider).clear(); ref.invalidate(currentSessionProvider); if (context.mounted) context.go('/login'); } return; } else if (value == 'profile') { context.go('/profile'); } }, itemBuilder: (context) => const [ PopupMenuItem( value: 'profile', child: ListTile( dense: true, leading: Icon(Icons.person_outline_rounded), title: Text('Mi perfil'), contentPadding: EdgeInsets.zero, ), ), PopupMenuDivider(), PopupMenuItem( value: 'logout', child: ListTile( dense: true, leading: Icon(Icons.logout_rounded, color: AppTokens.danger), title: Text('Cerrar sesion', style: TextStyle(color: AppTokens.danger)), contentPadding: EdgeInsets.zero, ), ), ], child: CircleAvatar( radius: 18, backgroundColor: AppTokens.primary.withValues(alpha: 0.12), child: const Icon(Icons.person_rounded, size: 20, color: AppTokens.primary), ), ), ], ), ), // Page content Expanded(child: child), ], ), ), ], ), // Bottom navigation with center button bottomNavigationBar: SafeArea( minimum: const EdgeInsets.fromLTRB(12, 0, 12, 10), child: Container( height: 74, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(24), border: Border.all(color: AppTokens.border.withValues(alpha: 0.6)), boxShadow: [ BoxShadow( color: AppTokens.textPrimary.withValues(alpha: 0.06), blurRadius: 24, offset: const Offset(0, 8), ), ], ), // Inventario/Reportes solo si el usuario tiene el modulo real (o un // reemplazo de _fallbackCandidates); Inicio, Vender y Mas siempre. child: Row( children: [ _NavItem( icon: _tabs[0].icon, activeIcon: _tabs[0].activeIcon, label: _tabs[0].label, isActive: location.startsWith(_tabs[0].route), onTap: () => context.go(_tabs[0].route), ), if (leftSlot != null) _NavItem( icon: leftSlot.icon, activeIcon: leftSlot.activeIcon, label: leftSlot.label, isActive: location.startsWith(leftSlot.route), onTap: () => context.go(leftSlot.route), ) else const Expanded(child: SizedBox.shrink()), Expanded( child: _CenterSellButton( isActive: isSalesActive, onTap: () => context.go('/sales'), ), ), if (rightSlot != null) _NavItem( icon: rightSlot.icon, activeIcon: rightSlot.activeIcon, label: rightSlot.label, isActive: location.startsWith(rightSlot.route), onTap: () => context.go(rightSlot.route), ) else const Expanded(child: SizedBox.shrink()), _NavItem( icon: _tabs[3].icon, activeIcon: _tabs[3].activeIcon, label: _tabs[3].label, isActive: location.startsWith(_tabs[3].route), onTap: () => context.go(_tabs[3].route), ), ], ), ), ), ), ); } /// Prioridad del botón/gesto físico atrás: /// 1. Si hay algo para popear (pantalla empujada encima) -> pop explícito /// (con `canPop: false` el pop automático queda bloqueado). /// 2. Si estamos parados en la raíz de una pestaña que no es Inicio -> /// volver a Inicio en vez de salir (protege de cerrar la app sin /// querer con 8 pestañas). /// 3. Si estamos en la raíz de Inicio -> salir directo, salvo que haya /// una venta en curso sin cobrar (ahí sí, confirmar). Future _handleBackPress( BuildContext context, WidgetRef ref, { required bool isHome, }) async { final router = GoRouter.of(context); if (router.canPop()) { router.pop(); return; } final hasUnsavedSale = ref.read(hasUnsavedSaleCartProvider); if (!isHome) { if (hasUnsavedSale) { final leave = await _confirmLeaveUnsavedSale(context, exitApp: false); if (leave == true && context.mounted) context.go('/home'); return; } context.go('/home'); return; } if (hasUnsavedSale) { final leave = await _confirmLeaveUnsavedSale(context, exitApp: true); if (leave == true) SystemNavigator.pop(); return; } SystemNavigator.pop(); } Future _confirmLeaveUnsavedSale( BuildContext context, { required bool exitApp, }) { return showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Tienes una venta sin cerrar'), content: Text( exitApp ? 'Hay una venta en curso sin cobrar. Si sales de la app se perderá.' : 'Hay una venta en curso sin cobrar. Si continúas se perderá.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Seguir aquí'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Salir igual'), ), ], ), ); } } /// Individual nav tab item. class _NavItem extends StatelessWidget { const _NavItem({ required this.icon, required this.activeIcon, required this.label, required this.isActive, required this.onTap, }); final IconData icon; final IconData activeIcon; final String label; final bool isActive; final VoidCallback onTap; @override Widget build(BuildContext context) { return Expanded( child: Semantics( label: label, selected: isActive, button: true, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5), decoration: BoxDecoration( color: isActive ? AppTokens.primary.withValues(alpha: 0.10) : Colors.transparent, borderRadius: BorderRadius.circular(14), ), child: Icon( isActive ? activeIcon : icon, size: 22, color: isActive ? AppTokens.primary : AppTokens.secondary, ), ), const SizedBox(height: 4), Text( label, style: TextStyle( fontSize: 10.5, fontWeight: isActive ? FontWeight.w700 : FontWeight.w500, color: isActive ? AppTokens.primary : AppTokens.secondary, ), ), ], ), ), ), ); } } /// Prominent center button for "Vender". class _CenterSellButton extends StatelessWidget { const _CenterSellButton({ required this.isActive, required this.onTap, }); final bool isActive; final VoidCallback onTap; @override Widget build(BuildContext context) { final color = isActive ? AppTokens.cta : AppTokens.primary; return Semantics( label: 'Vender', selected: isActive, button: true, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(28), child: SizedBox( height: 74, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: color, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: color.withValues(alpha: 0.30), blurRadius: 10, offset: const Offset(0, 3), ), ], ), child: const Icon( Icons.point_of_sale_rounded, color: Colors.white, size: 22, ), ), const SizedBox(height: 2), Text( 'Vender', style: TextStyle( fontSize: 10.5, fontWeight: FontWeight.w700, color: color, ), ), ], ), ), ), ); } }