From dee9e52ae0d3cde6f8ad30ed8323052a4cb14e05 Mon Sep 17 00:00:00 2001 From: Abraham Date: Mon, 21 Jul 2025 21:09:34 -0700 Subject: [PATCH] =?UTF-8?q?mapa=20con=20marcador=20a=C3=B1adido=20beta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../referencia/nethive_tablas_actualizado.md | 293 ++++ .../empresa_negocios_page.dart | 86 +- .../widgets/negocios_map_view.dart | 685 +++++++++ .../infrastructure/pages/topologia_page.dart | 1333 +++++++++++++++-- pubspec.lock | 80 + pubspec.yaml | 4 + 6 files changed, 2316 insertions(+), 165 deletions(-) create mode 100644 assets/referencia/nethive_tablas_actualizado.md create mode 100644 lib/pages/empresa_negocios/widgets/negocios_map_view.dart diff --git a/assets/referencia/nethive_tablas_actualizado.md b/assets/referencia/nethive_tablas_actualizado.md new file mode 100644 index 0000000..196aae7 --- /dev/null +++ b/assets/referencia/nethive_tablas_actualizado.md @@ -0,0 +1,293 @@ + +# 📘 Estructura de Base de Datos — NETHIVE (Esquema: `nethive`) + +--- + +## 🏢 `empresa` + +| Columna | Tipo de dato | Descripción | +|-----------------|----------------|-----------------------------------| +| id | UUID | PRIMARY KEY | +| nombre | TEXT | Nombre de la empresa | +| rfc | TEXT | Registro fiscal | +| direccion | TEXT | Dirección | +| telefono | TEXT | Teléfono de contacto | +| email | TEXT | Correo electrónico | +| fecha_creacion | TIMESTAMP | Fecha de creación (default: now) | +| logo_url | TEXT | URL del logo | +| imagen_url | TEXT | URL de imagen principal | + +--- + +## 🏪 `negocio` + +| Columna | Tipo de dato | Descripción | +|-----------------|----------------|-----------------------------------| +| id | UUID | PRIMARY KEY | +| empresa_id | UUID | FK → empresa.id | +| nombre | TEXT | Nombre del negocio | +| direccion | TEXT | Dirección | +| latitud | DECIMAL(9,6) | Latitud geográfica | +| longitud | DECIMAL(9,6) | Longitud geográfica | +| tipo_local | TEXT | Tipo de local (Sucursal, etc.) | +| fecha_creacion | TIMESTAMP | Default: now() | +| logo_url | TEXT | Logo del negocio | +| imagen_url | TEXT | Imagen del negocio | + +--- + +## 🧾 `categoria_componente` + +| Columna | Tipo de dato | Descripción | +|---------|--------------|--------------------------| +| id | SERIAL | PRIMARY KEY | +| nombre | TEXT | Nombre único de categoría| + +--- + +## 📦 `componente` + +| Columna | Tipo de dato | Descripción | +|-----------------|----------------|------------------------------------| +| id | UUID | PRIMARY KEY | +| negocio_id | UUID | FK → negocio.id | +| categoria_id | INT | FK → categoria_componente.id | +| nombre | TEXT | Nombre del componente | +| descripcion | TEXT | Descripción general | +| en_uso | BOOLEAN | Si está en uso | +| activo | BOOLEAN | Si está activo | +| ubicacion | TEXT | Ubicación física (rack, bandeja) | +| imagen_url | TEXT | URL de imagen | +| fecha_registro | TIMESTAMP | Default: now() | + +--- + +## 🔌 `detalle_cable` + +| Columna | Tipo de dato | +|----------------|----------------| +| componente_id | UUID (PK, FK) | +| tipo_cable | TEXT | +| color | TEXT | +| tamaño | DECIMAL(5,2) | +| tipo_conector | TEXT | + +--- + +## 📶 `detalle_switch` + +| Columna | Tipo de dato | +|----------------------|----------------| +| componente_id | UUID (PK, FK) | +| marca | TEXT | +| modelo | TEXT | +| numero_serie | TEXT | +| administrable | BOOLEAN | +| poe | BOOLEAN | +| cantidad_puertos | INT | +| velocidad_puertos | TEXT | +| tipo_puertos | TEXT | +| ubicacion_en_rack | TEXT | +| direccion_ip | TEXT | +| firmware | TEXT | + +--- + +## 🧱 `detalle_patch_panel` + +| Columna | Tipo de dato | +|---------------------|----------------| +| componente_id | UUID (PK, FK) | +| tipo_conector | TEXT | +| numero_puertos | INT | +| categoria | TEXT | +| tipo_montaje | TEXT | +| numeracion_frontal | BOOLEAN | +| panel_ciego | BOOLEAN | + +--- + +## 🗄 `detalle_rack` + +| Columna | Tipo de dato | +|------------------------|----------------| +| componente_id | UUID (PK, FK) | +| tipo | TEXT | +| altura_u | INT | +| profundidad_cm | INT | +| ancho_cm | INT | +| ventilacion_integrada | BOOLEAN | +| puertas_con_llave | BOOLEAN | +| ruedas | BOOLEAN | +| color | TEXT | + +--- + +## 🧰 `detalle_organizador` + +| Columna | Tipo de dato | +|--------------|----------------| +| componente_id| UUID (PK, FK) | +| tipo | TEXT | +| material | TEXT | +| tamaño | TEXT | +| color | TEXT | + +--- + +## ⚡ `detalle_ups` + +| Columna | Tipo de dato | +|--------------------|----------------| +| componente_id | UUID (PK, FK) | +| tipo | TEXT | +| marca | TEXT | +| modelo | TEXT | +| voltaje_entrada | TEXT | +| voltaje_salida | TEXT | +| capacidad_va | INT | +| autonomia_minutos | INT | +| cantidad_tomas | INT | +| rackeable | BOOLEAN | + +--- + +## 🔐 `detalle_router_firewall` + +| Columna | Tipo de dato | +|--------------------------|----------------| +| componente_id | UUID (PK, FK) | +| tipo | TEXT | +| marca | TEXT | +| modelo | TEXT | +| numero_serie | TEXT | +| interfaces | TEXT | +| capacidad_routing_gbps | DECIMAL(5,2) | +| direccion_ip | TEXT | +| firmware | TEXT | +| licencias | TEXT | + +--- + +## 🧿 `detalle_equipo_activo` + +| Columna | Tipo de dato | +|-------------------|----------------| +| componente_id | UUID (PK, FK) | +| tipo | TEXT | +| marca | TEXT | +| modelo | TEXT | +| numero_serie | TEXT | +| especificaciones | TEXT | +| direccion_ip | TEXT | +| firmware | TEXT | + +--- + +## 🧭 `distribucion` + +| Columna | Tipo de dato | Descripción | +|--------------|----------------|--------------------------------------| +| id | UUID | PRIMARY KEY | +| negocio_id | UUID | FK → negocio.id | +| tipo | TEXT | 'MDF' o 'IDF' | +| nombre | TEXT | Nombre de la ubicación lógica | +| descripcion | TEXT | Detalles adicionales (opcional) | + +--- + +## 🔗 `conexion_componente` + +| Columna | Tipo de dato | Descripción | +|-----------------------|----------------|------------------------------------------| +| id | UUID | PRIMARY KEY | +| componente_origen_id | UUID | FK → componente.id | +| componente_destino_id | UUID | FK → componente.id | +| descripcion | TEXT | Descripción de la conexión (opcional) | +| activo | BOOLEAN | Si la conexión está activa | + +--- + + + +## 👁️ `vista_negocios_con_coordenadas` + +| Columna | Tipo de dato | Descripción | +|--------------------|--------------|--------------------------------------------| +| negocio_id | UUID | ID del negocio | +| nombre_negocio | TEXT | Nombre del negocio | +| latitud | DECIMAL | Latitud del negocio | +| longitud | DECIMAL | Longitud del negocio | +| logo_negocio | TEXT | URL del logo del negocio | +| imagen_negocio | TEXT | URL de la imagen del negocio | +| empresa_id | UUID | ID de la empresa | +| nombre_empresa | TEXT | Nombre de la empresa | +| logo_empresa | TEXT | URL del logo de la empresa | +| imagen_empresa | TEXT | URL de la imagen de la empresa | + +--- + +## 📋 `vista_inventario_por_negocio` + +| Columna | Tipo de dato | Descripción | +|--------------------|--------------|---------------------------------------------| +| componente_id | UUID | ID del componente | +| nombre_componente | TEXT | Nombre del componente | +| categoria | TEXT | Categoría del componente | +| en_uso | BOOLEAN | Si está en uso | +| activo | BOOLEAN | Si está activo | +| ubicacion | TEXT | Ubicación física del componente | +| imagen_componente | TEXT | Imagen asociada al componente | +| negocio_id | UUID | ID del negocio | +| nombre_negocio | TEXT | Nombre del negocio | +| logo_negocio | TEXT | Logo del negocio | +| imagen_negocio | TEXT | Imagen del negocio | +| empresa_id | UUID | ID de la empresa | +| nombre_empresa | TEXT | Nombre de la empresa | +| logo_empresa | TEXT | Logo de la empresa | +| imagen_empresa | TEXT | Imagen de la empresa | + +--- + +## 🧵 `vista_detalle_cables` + +| Columna | Tipo de dato | Descripción | +|--------------------|--------------|--------------------------------------------| +| componente_id | UUID | ID del componente | +| nombre | TEXT | Nombre del cable | +| tipo_cable | TEXT | Tipo de cable (UTP, fibra, etc.) | +| color | TEXT | Color del cable | +| tamaño | DECIMAL | Longitud del cable | +| tipo_conector | TEXT | Tipo de conector (RJ45, LC, etc.) | +| en_uso | BOOLEAN | Si está en uso | +| activo | BOOLEAN | Si está activo | +| ubicacion | TEXT | Ubicación física | +| imagen_componente | TEXT | Imagen del cable | +| nombre_negocio | TEXT | Nombre del negocio | +| logo_negocio | TEXT | Logo del negocio | +| nombre_empresa | TEXT | Nombre de la empresa | +| logo_empresa | TEXT | Logo de la empresa | + +--- + +## 📊 `vista_resumen_componentes_activos` + +| Columna | Tipo de dato | Descripción | +|------------------|--------------|----------------------------------------------| +| nombre_empresa | TEXT | Nombre de la empresa | +| nombre_negocio | TEXT | Nombre del negocio | +| categoria | TEXT | Categoría del componente | +| cantidad_activos | INTEGER | Cantidad total de componentes activos | + +--- + +## 🔌 `vista_conexiones_por_negocio` + +| Columna | Tipo de dato | Descripción | +|-----------------------|--------------|------------------------------------------| +| id | UUID | ID de la conexión | +| componente_origen_id | UUID | Componente origen | +| componente_destino_id | UUID | Componente destino | +| descripcion | TEXT | Descripción de la conexión | +| activo | BOOLEAN | Si la conexión está activa | +""" diff --git a/lib/pages/empresa_negocios/empresa_negocios_page.dart b/lib/pages/empresa_negocios/empresa_negocios_page.dart index 8457796..c09d3f6 100644 --- a/lib/pages/empresa_negocios/empresa_negocios_page.dart +++ b/lib/pages/empresa_negocios/empresa_negocios_page.dart @@ -5,8 +5,8 @@ import 'package:nethive_neo/pages/empresa_negocios/widgets/empresa_selector_side import 'package:nethive_neo/pages/empresa_negocios/widgets/negocios_table.dart'; import 'package:nethive_neo/pages/empresa_negocios/widgets/negocios_cards_view.dart'; import 'package:nethive_neo/pages/empresa_negocios/widgets/mobile_empresa_selector.dart'; +import 'package:nethive_neo/pages/empresa_negocios/widgets/negocios_map_view.dart'; import 'package:nethive_neo/theme/theme.dart'; -import 'package:nethive_neo/helpers/globals.dart'; class EmpresaNegociosPage extends StatefulWidget { const EmpresaNegociosPage({Key? key}) : super(key: key); @@ -55,7 +55,6 @@ class _EmpresaNegociosPageState extends State @override Widget build(BuildContext context) { final isLargeScreen = MediaQuery.of(context).size.width > 1200; - final isMediumScreen = MediaQuery.of(context).size.width > 800; return Scaffold( backgroundColor: AppTheme.of(context).primaryBackground, @@ -589,85 +588,10 @@ class _EmpresaNegociosPageState extends State } Widget _buildMapView() { - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Colors.blue.withOpacity(0.1), - Colors.blue.withOpacity(0.3), - AppTheme.of(context).primaryColor.withOpacity(0.2), - ], - ), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppTheme.of(context).primaryColor, - width: 2, - ), - boxShadow: [ - BoxShadow( - color: AppTheme.of(context).primaryColor.withOpacity(0.2), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Center( - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 1500), - tween: Tween(begin: 0.0, end: 1.0), - builder: (context, value, child) { - return Transform.scale( - scale: 0.8 + (0.2 * value), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: AppTheme.of(context).modernGradient, - borderRadius: BorderRadius.circular(20), - ), - child: Icon( - Icons.map, - size: 80, - color: Colors.white, - ), - ), - const SizedBox(height: 24), - Text( - 'Vista de Mapa', - style: TextStyle( - color: AppTheme.of(context).primaryColor, - fontSize: 28, - fontWeight: FontWeight.bold, - letterSpacing: 1, - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.9), - borderRadius: BorderRadius.circular(15), - ), - child: Text( - 'Próximamente se implementará el mapa interactivo\ncon las ubicaciones de todas las sucursales', - style: TextStyle( - color: AppTheme.of(context).primaryColor, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - ); - }, - ), - ), + return Consumer( + builder: (context, provider, child) { + return NegociosMapView(provider: provider); + }, ); } diff --git a/lib/pages/empresa_negocios/widgets/negocios_map_view.dart b/lib/pages/empresa_negocios/widgets/negocios_map_view.dart new file mode 100644 index 0000000..a85f7cb --- /dev/null +++ b/lib/pages/empresa_negocios/widgets/negocios_map_view.dart @@ -0,0 +1,685 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:go_router/go_router.dart'; +import 'package:nethive_neo/providers/nethive/empresas_negocios_provider.dart'; +import 'package:nethive_neo/theme/theme.dart'; + +class NegociosMapView extends StatefulWidget { + final EmpresasNegociosProvider provider; + + const NegociosMapView({ + Key? key, + required this.provider, + }) : super(key: key); + + @override + State createState() => _NegociosMapViewState(); +} + +class _NegociosMapViewState extends State + with TickerProviderStateMixin { + final MapController _mapController = MapController(); + late AnimationController _markerAnimationController; + late AnimationController _tooltipAnimationController; + late Animation _markerAnimation; + late Animation _tooltipAnimation; + late Animation _tooltipSlideAnimation; + + String? _hoveredNegocioId; + Offset? _tooltipPosition; + bool _showTooltip = false; + + @override + void initState() { + super.initState(); + _markerAnimationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _tooltipAnimationController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + + _markerAnimation = Tween( + begin: 1.0, + end: 1.4, + ).animate(CurvedAnimation( + parent: _markerAnimationController, + curve: Curves.easeOutBack, + )); + + _tooltipAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _tooltipAnimationController, + curve: Curves.easeOutCubic, + )); + + _tooltipSlideAnimation = Tween( + begin: const Offset(0, 0.5), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _tooltipAnimationController, + curve: Curves.easeOutCubic, + )); + + // Centrar el mapa después de que se construya + WidgetsBinding.instance.addPostFrameCallback((_) { + _centerMapOnNegocios(); + }); + } + + @override + void dispose() { + _markerAnimationController.dispose(); + _tooltipAnimationController.dispose(); + super.dispose(); + } + + void _showTooltipForNegocio(String negocioId, Offset position) { + setState(() { + _hoveredNegocioId = negocioId; + _tooltipPosition = Offset( + position.dx + 20, // Offset del cursor para evitar interferencia + position.dy - 80, // Arriba del cursor + ); + _showTooltip = true; + }); + + _markerAnimationController.forward(); + _tooltipAnimationController.forward(); + } + + void _hideTooltip() { + _tooltipAnimationController.reverse().then((_) { + if (mounted) { + setState(() { + _hoveredNegocioId = null; + _showTooltip = false; + _tooltipPosition = null; + }); + } + }); + _markerAnimationController.reverse(); + } + + void _centerMapOnNegocios() { + if (widget.provider.negocios.isNotEmpty) { + final bounds = _calculateBounds(); + _mapController.fitCamera( + CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(50), + maxZoom: 15, + ), + ); + } + } + + LatLngBounds _calculateBounds() { + if (widget.provider.negocios.isEmpty) { + return LatLngBounds( + const LatLng(-90, -180), + const LatLng(90, 180), + ); + } + + double minLat = widget.provider.negocios.first.latitud; + double maxLat = widget.provider.negocios.first.latitud; + double minLng = widget.provider.negocios.first.longitud; + double maxLng = widget.provider.negocios.first.longitud; + + for (final negocio in widget.provider.negocios) { + minLat = minLat < negocio.latitud ? minLat : negocio.latitud; + maxLat = maxLat > negocio.latitud ? maxLat : negocio.latitud; + minLng = minLng < negocio.longitud ? minLng : negocio.longitud; + maxLng = maxLng > negocio.longitud ? maxLng : negocio.longitud; + } + + return LatLngBounds( + LatLng(minLat, minLng), + LatLng(maxLat, maxLng), + ); + } + + @override + Widget build(BuildContext context) { + if (widget.provider.negocios.isEmpty) { + return _buildEmptyMapState(); + } + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + children: [ + // Listener para detectar movimientos del mouse sobre el mapa + MouseRegion( + onExit: (_) => _hideTooltip(), + child: FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: const LatLng(19.4326, -99.1332), + initialZoom: 10, + minZoom: 3, + maxZoom: 18, + ), + children: [ + // Capa de tiles del mapa + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.nethive.app', + ), + + // Capa de marcadores + MarkerLayer( + markers: _buildMarkers(), + ), + ], + ), + ), + + // Header del mapa con información + Positioned( + top: 20, + left: 20, + right: 20, + child: _buildMapHeader(), + ), + + // Controles del mapa + Positioned( + bottom: 20, + right: 20, + child: _buildMapControls(), + ), + + // Tooltip flotante con información del negocio + if (_showTooltip && _tooltipPosition != null) + Positioned( + left: _tooltipPosition!.dx + .clamp(20.0, MediaQuery.of(context).size.width - 280), + top: _tooltipPosition!.dy + .clamp(20.0, MediaQuery.of(context).size.height - 150), + child: _buildAnimatedTooltip(), + ), + ], + ), + ), + ); + } + + List _buildMarkers() { + return widget.provider.negocios.map((negocio) { + final isHovered = _hoveredNegocioId == negocio.id; + + return Marker( + point: LatLng(negocio.latitud, negocio.longitud), + width: 50, + height: 50, + child: MouseRegion( + onEnter: (event) { + _showTooltipForNegocio(negocio.id, event.position); + }, + onHover: (event) { + // Actualizar posición del tooltip sin recalcular todo + if (_hoveredNegocioId == negocio.id) { + setState(() { + _tooltipPosition = Offset( + event.position.dx + 20, + event.position.dy - 80, + ); + }); + } + }, + child: GestureDetector( + onTap: () { + // Navegar a la infraestructura del negocio + context.go('/infrastructure/${negocio.id}'); + }, + child: AnimatedBuilder( + animation: _markerAnimation, + builder: (context, child) { + return Transform.scale( + scale: isHovered ? _markerAnimation.value : 1.0, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: isHovered + ? AppTheme.of(context).primaryGradient + : AppTheme.of(context).modernGradient, + boxShadow: [ + BoxShadow( + color: AppTheme.of(context) + .primaryColor + .withOpacity(0.4), + blurRadius: isHovered ? 15 : 8, + offset: const Offset(0, 4), + ), + ], + border: Border.all( + color: Colors.white, + width: isHovered ? 3 : 2, + ), + ), + child: Icon( + Icons.store, + color: Colors.white, + size: isHovered ? 22 : 18, + ), + ), + ); + }, + ), + ), + ), + ); + }).toList(); + } + + Widget _buildAnimatedTooltip() { + final negocio = widget.provider.negocios.firstWhere( + (n) => n.id == _hoveredNegocioId, + ); + + return SlideTransition( + position: _tooltipSlideAnimation, + child: FadeTransition( + opacity: _tooltipAnimation, + child: ScaleTransition( + scale: _tooltipAnimation, + child: Container( + width: 260, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: AppTheme.of(context).modernGradient, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: AppTheme.of(context).primaryColor.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.store, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + negocio.nombre, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.location_on, + color: Colors.white.withOpacity(0.8), + size: 16, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + negocio.direccion, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.business, + color: Colors.white.withOpacity(0.8), + size: 16, + ), + const SizedBox(width: 6), + Text( + negocio.tipoLocal, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.touch_app, + color: Colors.white, + size: 14, + ), + const SizedBox(width: 6), + Text( + 'Clic para ver infraestructura', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildMapHeader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: AppTheme.of(context).primaryGradient, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: AppTheme.of(context).primaryColor.withOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.map, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Mapa de Sucursales', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + '${widget.provider.negocios.length} ubicaciones de ${widget.provider.empresaSeleccionada?.nombre ?? ""}', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.location_on, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 6), + Text( + 'OpenStreetMap', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMapControls() { + return Column( + children: [ + // Botón de centrar mapa + Container( + decoration: BoxDecoration( + gradient: AppTheme.of(context).primaryGradient, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: AppTheme.of(context).primaryColor.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _centerMapOnNegocios, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.center_focus_strong, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ), + const SizedBox(height: 12), + + // Botón de zoom in + Container( + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + _mapController.move( + _mapController.camera.center, + _mapController.camera.zoom + 1, + ); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.add, + color: AppTheme.of(context).primaryColor, + size: 20, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + + // Botón de zoom out + Container( + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + _mapController.move( + _mapController.camera.center, + _mapController.camera.zoom - 1, + ); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.remove, + color: AppTheme.of(context).primaryColor, + size: 20, + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildEmptyMapState() { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.blue.withOpacity(0.1), + Colors.blue.withOpacity(0.3), + AppTheme.of(context).primaryColor.withOpacity(0.2), + ], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppTheme.of(context).primaryColor, + width: 2, + ), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppTheme.of(context).modernGradient, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.location_off, + size: 60, + color: Colors.white, + ), + ), + const SizedBox(height: 20), + Text( + 'Sin ubicaciones para mostrar', + style: TextStyle( + color: AppTheme.of(context).primaryColor, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + 'Selecciona una empresa con sucursales\npara ver sus ubicaciones en el mapa', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.of(context).secondaryText, + fontSize: 16, + ), + ), + ], + ), + ), + ); + } + + bool get isHovered => _hoveredNegocioId != null; +} diff --git a/lib/pages/infrastructure/pages/topologia_page.dart b/lib/pages/infrastructure/pages/topologia_page.dart index 47879d1..48ac33d 100644 --- a/lib/pages/infrastructure/pages/topologia_page.dart +++ b/lib/pages/infrastructure/pages/topologia_page.dart @@ -1,103 +1,146 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:nethive_neo/theme/theme.dart'; +import 'package:nethive_neo/providers/nethive/componentes_provider.dart'; -class TopologiaPage extends StatelessWidget { +class TopologiaPage extends StatefulWidget { const TopologiaPage({Key? key}) : super(key: key); + @override + State createState() => _TopologiaPageState(); +} + +class _TopologiaPageState extends State + with TickerProviderStateMixin { + late AnimationController _animationController; + late Animation _fadeAnimation; + String _selectedView = 'rack'; // rack, network, floor + double _zoomLevel = 1.0; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + )); + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: AppTheme.of(context).primaryGradient, - borderRadius: BorderRadius.circular(16), - ), - child: Row( + final isLargeScreen = MediaQuery.of(context).size.width > 1200; + final isMediumScreen = MediaQuery.of(context).size.width > 800; + + return FadeTransition( + opacity: _fadeAnimation, + child: Consumer( + builder: (context, componentesProvider, child) { + return Container( + padding: EdgeInsets.all(isMediumScreen ? 24 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.account_tree, + // Header mejorado + _buildTopologyHeader(), + + const SizedBox(height: 24), + + // Controles de vista y zoom + if (isMediumScreen) ...[ + _buildTopologyControls(), + const SizedBox(height: 24), + ], + + // Vista principal de topología + Expanded( + child: + _buildTopologyView(componentesProvider, isMediumScreen), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildTopologyHeader() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppTheme.of(context).primaryGradient, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppTheme.of(context).primaryColor.withOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.account_tree, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Topología de Red MDF/IDF', + style: TextStyle( color: Colors.white, - size: 24, + fontSize: 24, + fontWeight: FontWeight.bold, ), ), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Topología de Red', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - Text( - 'Visualización de la infraestructura MDF/IDF', - style: TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - ], + Text( + 'Visualización interactiva de la infraestructura de telecomunicaciones', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, ), ), ], ), ), - - const SizedBox(height: 24), - - // Contenido próximamente - Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: AppTheme.of(context).modernGradient, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.account_tree, - size: 60, - color: Colors.white, - ), - ), - const SizedBox(height: 20), - Text( - 'Topología de Red', - style: TextStyle( - color: AppTheme.of(context).primaryText, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - Text( - 'Visualización interactiva de la red MDF/IDF\nPróximamente disponible', - textAlign: TextAlign.center, - style: TextStyle( - color: AppTheme.of(context).secondaryText, - fontSize: 16, - ), - ), - ], + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'V2.0', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, ), ), ), @@ -105,4 +148,1126 @@ class TopologiaPage extends StatelessWidget { ), ); } + + Widget _buildTopologyControls() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.of(context).primaryColor.withOpacity(0.2), + ), + ), + child: Row( + children: [ + // Selector de vista + Expanded( + child: Row( + children: [ + Text( + 'Vista:', + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 12), + _buildViewButton('rack', 'Rack', Icons.dns), + const SizedBox(width: 8), + _buildViewButton('network', 'Red', Icons.hub), + const SizedBox(width: 8), + _buildViewButton('floor', 'Planta', Icons.map), + ], + ), + ), + + // Controles de zoom + Row( + children: [ + Text( + 'Zoom:', + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 12), + IconButton( + onPressed: () => setState( + () => _zoomLevel = (_zoomLevel - 0.2).clamp(0.5, 2.0)), + icon: const Icon(Icons.zoom_out), + tooltip: 'Alejar', + ), + Text( + '${(_zoomLevel * 100).round()}%', + style: TextStyle( + color: AppTheme.of(context).secondaryText, + fontSize: 12, + ), + ), + IconButton( + onPressed: () => setState( + () => _zoomLevel = (_zoomLevel + 0.2).clamp(0.5, 2.0)), + icon: const Icon(Icons.zoom_in), + tooltip: 'Acercar', + ), + IconButton( + onPressed: () => setState(() => _zoomLevel = 1.0), + icon: const Icon(Icons.center_focus_strong), + tooltip: 'Restablecer zoom', + ), + ], + ), + ], + ), + ); + } + + Widget _buildViewButton(String value, String label, IconData icon) { + final isSelected = _selectedView == value; + return GestureDetector( + onTap: () => setState(() => _selectedView = value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.of(context).primaryColor + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? AppTheme.of(context).primaryColor + : AppTheme.of(context).primaryColor.withOpacity(0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: + isSelected ? Colors.white : AppTheme.of(context).primaryColor, + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: isSelected + ? Colors.white + : AppTheme.of(context).primaryColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } + + Widget _buildTopologyView( + ComponentesProvider componentesProvider, bool isMediumScreen) { + return Container( + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.of(context).primaryColor.withOpacity(0.2), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + // Fondo con patrón de cuadrícula + _buildGridBackground(), + + // Contenido principal según la vista seleccionada + Transform.scale( + scale: _zoomLevel, + child: _buildViewContent(componentesProvider, isMediumScreen), + ), + + // Leyenda flotante + if (isMediumScreen) + Positioned( + top: 16, + right: 16, + child: _buildLegend(), + ), + + // Controles móviles + if (!isMediumScreen) + Positioned( + bottom: 16, + left: 16, + right: 16, + child: _buildMobileControls(), + ), + ], + ), + ), + ); + } + + Widget _buildGridBackground() { + return CustomPaint( + painter: GridPainter( + color: AppTheme.of(context).primaryColor.withOpacity(0.1), + ), + child: Container(), + ); + } + + Widget _buildViewContent( + ComponentesProvider componentesProvider, bool isMediumScreen) { + switch (_selectedView) { + case 'rack': + return _buildRackView(componentesProvider, isMediumScreen); + case 'network': + return _buildNetworkView(componentesProvider, isMediumScreen); + case 'floor': + return _buildFloorView(componentesProvider, isMediumScreen); + default: + return _buildRackView(componentesProvider, isMediumScreen); + } + } + + Widget _buildRackView( + ComponentesProvider componentesProvider, bool isMediumScreen) { + final racks = componentesProvider.componentes + .where((c) => + componentesProvider + .getCategoriaById(c.categoriaId) + ?.nombre + ?.toLowerCase() + .contains('rack') ?? + false) + .toList(); + + if (racks.isEmpty) { + return _buildEmptyRackView(); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: isMediumScreen + ? _buildDesktopRackLayout(racks, componentesProvider) + : _buildMobileRackLayout(racks, componentesProvider), + ); + } + + Widget _buildEmptyRackView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.of(context).primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.dns, + size: 48, + color: AppTheme.of(context).primaryColor, + ), + ), + const SizedBox(height: 16), + Text( + 'No hay racks configurados', + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Agregue racks desde el inventario para visualizar la topología', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.of(context).secondaryText, + fontSize: 14, + ), + ), + ], + ), + ); + } + + Widget _buildDesktopRackLayout( + List racks, ComponentesProvider componentesProvider) { + return Wrap( + spacing: 32, + runSpacing: 32, + children: racks + .map((rack) => _buildRackWidget(rack, componentesProvider, true)) + .toList(), + ); + } + + Widget _buildMobileRackLayout( + List racks, ComponentesProvider componentesProvider) { + return Column( + children: racks + .map((rack) => Container( + margin: const EdgeInsets.only(bottom: 24), + child: _buildRackWidget(rack, componentesProvider, false), + )) + .toList(), + ); + } + + Widget _buildRackWidget( + dynamic rack, ComponentesProvider componentesProvider, bool isDesktop) { + // Obtener componentes que están en este rack + final rackComponents = componentesProvider.componentes + .where((c) => + c.ubicacion?.toLowerCase().contains(rack.nombre.toLowerCase()) ?? + false) + .toList(); + + return Container( + width: isDesktop ? 280 : double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.grey[800]!, + Colors.grey[900]!, + ], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[600]!, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header del rack + Row( + children: [ + Icon( + Icons.dns, + color: Colors.blue[300], + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + rack.nombre, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: rack.activo ? Colors.green : Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + rack.activo ? 'ACTIVO' : 'INACTIVO', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Unidades del rack (simulación visual) + Container( + height: 200, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.grey[700]!), + ), + child: _buildRackUnits(rackComponents, componentesProvider), + ), + + const SizedBox(height: 12), + + // Información del rack + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${rackComponents.length} componentes', + style: TextStyle( + color: Colors.grey[300], + fontSize: 12, + ), + ), + if (rack.ubicacion != null) + Text( + rack.ubicacion!, + style: TextStyle( + color: Colors.grey[400], + fontSize: 10, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildRackUnits( + List components, ComponentesProvider componentesProvider) { + return ListView.builder( + padding: const EdgeInsets.all(4), + itemCount: + (components.length + 1).clamp(1, 10), // Máximo 10 unidades visuales + itemBuilder: (context, index) { + if (index < components.length) { + final component = components[index]; + final categoria = + componentesProvider.getCategoriaById(component.categoriaId); + return Container( + height: 16, + margin: const EdgeInsets.only(bottom: 2), + decoration: BoxDecoration( + color: _getComponentColor(categoria?.nombre), + borderRadius: BorderRadius.circular(2), + border: Border.all(color: Colors.grey[600]!, width: 0.5), + ), + child: Row( + children: [ + const SizedBox(width: 4), + Icon( + _getComponentIcon(categoria?.nombre), + size: 10, + color: Colors.white, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + component.nombre, + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } else { + // Unidad vacía + return Container( + height: 16, + margin: const EdgeInsets.only(bottom: 2), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(2), + border: Border.all(color: Colors.grey[700]!, width: 0.5), + ), + ); + } + }, + ); + } + + Widget _buildNetworkView( + ComponentesProvider componentesProvider, bool isMediumScreen) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: _buildNetworkDiagram(componentesProvider, isMediumScreen), + ), + ); + } + + Widget _buildNetworkDiagram( + ComponentesProvider componentesProvider, bool isMediumScreen) { + final switches = componentesProvider.componentes + .where((c) => + componentesProvider + .getCategoriaById(c.categoriaId) + ?.nombre + ?.toLowerCase() + .contains('switch') ?? + false) + .toList(); + + final routers = componentesProvider.componentes + .where((c) => + componentesProvider + .getCategoriaById(c.categoriaId) + ?.nombre + ?.toLowerCase() + .contains('router') ?? + false) + .toList(); + + return Column( + children: [ + // Capa de routers/firewall + if (routers.isNotEmpty) ...[ + Text( + 'Capa de Enrutamiento', + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 24, + runSpacing: 16, + children: routers + .map((router) => _buildNetworkNode( + router, + Icons.router, + Colors.red[400]!, + componentesProvider, + )) + .toList(), + ), + const SizedBox(height: 32), + + // Líneas de conexión + Container( + height: 2, + width: isMediumScreen ? 200 : 150, + color: AppTheme.of(context).primaryColor.withOpacity(0.5), + ), + const SizedBox(height: 32), + ], + + // Capa de switches + if (switches.isNotEmpty) ...[ + Text( + 'Capa de Conmutación', + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 24, + runSpacing: 16, + children: switches + .map((switch_) => _buildNetworkNode( + switch_, + Icons.hub, + Colors.blue[400]!, + componentesProvider, + )) + .toList(), + ), + ], + + if (switches.isEmpty && routers.isEmpty) _buildEmptyNetworkView(), + ], + ); + } + + Widget _buildNetworkNode(dynamic component, IconData icon, Color color, + ComponentesProvider componentesProvider) { + return GestureDetector( + onTap: () => _showComponentDetails(component, componentesProvider), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.5), width: 2), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(height: 8), + Text( + component.nombre, + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + if (component.ubicacion != null) + Text( + component.ubicacion!, + style: TextStyle( + color: AppTheme.of(context).secondaryText, + fontSize: 10, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: component.activo ? Colors.green : Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + component.activo ? 'ON' : 'OFF', + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFloorView( + ComponentesProvider componentesProvider, bool isMediumScreen) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: _buildFloorPlan(componentesProvider, isMediumScreen), + ), + ); + } + + Widget _buildFloorPlan( + ComponentesProvider componentesProvider, bool isMediumScreen) { + return Container( + width: isMediumScreen ? 600 : double.infinity, + height: isMediumScreen ? 400 : 300, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[400]!), + ), + child: Stack( + children: [ + // Representación simple de planta + _buildFloorLayout(), + + // Componentes distribuidos en la planta + ..._buildFloorComponents(componentesProvider), + ], + ), + ); + } + + Widget _buildFloorLayout() { + return CustomPaint( + painter: FloorPlanPainter(), + child: Container(), + ); + } + + List _buildFloorComponents(ComponentesProvider componentesProvider) { + final components = componentesProvider.componentes + .take(6) + .toList(); // Límite para visualización + final positions = [ + const Offset(0.2, 0.3), + const Offset(0.8, 0.3), + const Offset(0.2, 0.7), + const Offset(0.8, 0.7), + const Offset(0.5, 0.2), + const Offset(0.5, 0.8), + ]; + + return components.asMap().entries.map((entry) { + final index = entry.key; + final component = entry.value; + final position = positions[index % positions.length]; + + return Positioned( + left: position.dx * 580 + 10, // Ajuste por padding + top: position.dy * 380 + 10, + child: _buildFloorComponent(component, componentesProvider), + ); + }).toList(); + } + + Widget _buildFloorComponent( + dynamic component, ComponentesProvider componentesProvider) { + final categoria = + componentesProvider.getCategoriaById(component.categoriaId); + + return GestureDetector( + onTap: () => _showComponentDetails(component, componentesProvider), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _getComponentColor(categoria?.nombre), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getComponentIcon(categoria?.nombre), + color: Colors.white, + size: 16, + ), + const SizedBox(height: 4), + Text( + component.nombre.length > 8 + ? '${component.nombre.substring(0, 8)}...' + : component.nombre, + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildEmptyNetworkView() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.of(context).primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.hub, + size: 48, + color: AppTheme.of(context).primaryColor, + ), + ), + const SizedBox(height: 16), + Text( + 'No hay equipos de red configurados', + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Agregue switches y routers desde el inventario', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.of(context).secondaryText, + fontSize: 14, + ), + ), + ], + ); + } + + Widget _buildLegend() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.of(context).primaryColor.withOpacity(0.2), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Leyenda', + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + _buildLegendItem(Icons.router, 'Router/Firewall', Colors.red[400]!), + _buildLegendItem(Icons.hub, 'Switch', Colors.blue[400]!), + _buildLegendItem(Icons.dns, 'Rack', Colors.grey[600]!), + _buildLegendItem(Icons.cable, 'Cable', Colors.orange[400]!), + _buildLegendItem( + Icons.electrical_services, 'UPS', Colors.yellow[700]!), + ], + ), + ); + } + + Widget _buildLegendItem(IconData icon, String label, Color color) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: AppTheme.of(context).secondaryText, + fontSize: 10, + ), + ), + ], + ), + ); + } + + Widget _buildMobileControls() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.of(context).primaryColor.withOpacity(0.2), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Selector de vista móvil + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildMobileViewButton('rack', 'Rack', Icons.dns), + _buildMobileViewButton('network', 'Red', Icons.hub), + _buildMobileViewButton('floor', 'Planta', Icons.map), + ], + ), + const SizedBox(height: 12), + + // Controles de zoom móvil + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () => setState( + () => _zoomLevel = (_zoomLevel - 0.2).clamp(0.5, 2.0)), + icon: const Icon(Icons.zoom_out), + iconSize: 20, + ), + Text( + '${(_zoomLevel * 100).round()}%', + style: TextStyle( + color: AppTheme.of(context).secondaryText, + fontSize: 12, + ), + ), + IconButton( + onPressed: () => setState( + () => _zoomLevel = (_zoomLevel + 0.2).clamp(0.5, 2.0)), + icon: const Icon(Icons.zoom_in), + iconSize: 20, + ), + ], + ), + ], + ), + ); + } + + Widget _buildMobileViewButton(String value, String label, IconData icon) { + final isSelected = _selectedView == value; + return GestureDetector( + onTap: () => setState(() => _selectedView = value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.of(context).primaryColor + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.of(context).primaryColor.withOpacity(0.3), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: + isSelected ? Colors.white : AppTheme.of(context).primaryColor, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: isSelected + ? Colors.white + : AppTheme.of(context).primaryColor, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } + + void _showComponentDetails( + dynamic component, ComponentesProvider componentesProvider) { + final categoria = + componentesProvider.getCategoriaById(component.categoriaId); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon( + _getComponentIcon(categoria?.nombre), + color: AppTheme.of(context).primaryColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + component.nombre, + style: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow('Categoría', categoria?.nombre ?? 'N/A'), + _buildDetailRow('Estado', component.activo ? 'Activo' : 'Inactivo'), + _buildDetailRow('En uso', component.enUso ? 'Sí' : 'No'), + if (component.ubicacion != null) + _buildDetailRow('Ubicación', component.ubicacion!), + if (component.descripcion != null) + _buildDetailRow('Descripción', component.descripcion!), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cerrar'), + ), + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + '$label:', + style: TextStyle( + color: AppTheme.of(context).secondaryText, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + color: AppTheme.of(context).primaryText, + ), + ), + ), + ], + ), + ); + } + + Color _getComponentColor(String? categoryName) { + if (categoryName == null) return Colors.grey[600]!; + + final name = categoryName.toLowerCase(); + if (name.contains('switch')) return Colors.blue[600]!; + if (name.contains('router') || name.contains('firewall')) + return Colors.red[600]!; + if (name.contains('cable')) return Colors.orange[600]!; + if (name.contains('rack')) return Colors.grey[700]!; + if (name.contains('ups')) return Colors.yellow[700]!; + if (name.contains('patch') || name.contains('panel')) + return Colors.purple[600]!; + return Colors.teal[600]!; + } + + IconData _getComponentIcon(String? categoryName) { + if (categoryName == null) return Icons.device_unknown; + + final name = categoryName.toLowerCase(); + if (name.contains('switch')) return Icons.hub; + if (name.contains('router') || name.contains('firewall')) + return Icons.router; + if (name.contains('cable')) return Icons.cable; + if (name.contains('rack')) return Icons.dns; + if (name.contains('ups')) return Icons.electrical_services; + if (name.contains('patch') || name.contains('panel')) + return Icons.view_module; + return Icons.memory; + } +} + +// Painter personalizado para la cuadrícula de fondo +class GridPainter extends CustomPainter { + final Color color; + + GridPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 0.5; + + const spacing = 20.0; + + // Líneas verticales + for (double x = 0; x < size.width; x += spacing) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + + // Líneas horizontales + for (double y = 0; y < size.height; y += spacing) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// Painter para el plano de planta +class FloorPlanPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey[400]! + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + // Dibujar habitaciones/áreas + final rect1 = + Rect.fromLTWH(20, 20, size.width * 0.4 - 20, size.height * 0.6); + final rect2 = Rect.fromLTWH( + size.width * 0.6, 20, size.width * 0.4 - 20, size.height * 0.6); + final rect3 = Rect.fromLTWH( + 20, size.height * 0.7, size.width - 40, size.height * 0.3 - 20); + + canvas.drawRect(rect1, paint); + canvas.drawRect(rect2, paint); + canvas.drawRect(rect3, paint); + + // Etiquetas de áreas + final textPainter = TextPainter( + textDirection: TextDirection.ltr, + ); + + textPainter.text = const TextSpan( + text: 'MDF', + style: TextStyle( + color: Colors.grey, fontSize: 12, fontWeight: FontWeight.bold), + ); + textPainter.layout(); + textPainter.paint(canvas, + Offset(rect1.center.dx - textPainter.width / 2, rect1.center.dy)); + + textPainter.text = const TextSpan( + text: 'IDF', + style: TextStyle( + color: Colors.grey, fontSize: 12, fontWeight: FontWeight.bold), + ); + textPainter.layout(); + textPainter.paint(canvas, + Offset(rect2.center.dx - textPainter.width / 2, rect2.center.dy)); + + textPainter.text = const TextSpan( + text: 'Área de Trabajo', + style: TextStyle( + color: Colors.grey, fontSize: 12, fontWeight: FontWeight.bold), + ); + textPainter.layout(); + textPainter.paint(canvas, + Offset(rect3.center.dx - textPainter.width / 2, rect3.center.dy)); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } diff --git a/pubspec.lock b/pubspec.lock index 2632a15..05c2717 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" dart_jsonwebtoken: dependency: "direct main" description: @@ -371,6 +379,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da" + url: "https://pub.dev" + source: hosted + version: "7.0.2" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -709,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -741,6 +765,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999" + url: "https://pub.dev" + source: hosted + version: "2.6.0" logging: dependency: transitive description: @@ -781,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -893,6 +941,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.8.0" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" postgrest: dependency: transitive description: @@ -909,6 +965,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" provider: dependency: "direct main" description: @@ -1202,6 +1266,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" url_launcher: dependency: "direct main" description: @@ -1394,6 +1466,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.5.4" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9e45c37..1fb4737 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,10 @@ dependencies: flutter_localizations: sdk: flutter + # Mapa interactivo + flutter_map: ^7.0.2 + latlong2: ^0.9.1 + # Paquetes adicionales del pubspec.yaml que te proporcionaron animated_toggle_switch: ^0.8.2 another_stepper: ^1.2.2