diff --git a/lib/models/network/network_device.dart b/lib/models/network/network_device.dart index bf19313f..76abbbbf 100644 --- a/lib/models/network/network_device.dart +++ b/lib/models/network/network_device.dart @@ -1,6 +1,52 @@ import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; import 'package:brick_supabase/brick_supabase.dart'; +enum NetworkDeviceStatus { + online, + offline, + warning, + unknown; + + String get wire { + switch (this) { + case NetworkDeviceStatus.online: + return 'online'; + case NetworkDeviceStatus.offline: + return 'offline'; + case NetworkDeviceStatus.warning: + return 'warning'; + case NetworkDeviceStatus.unknown: + return 'unknown'; + } + } + + String get label { + switch (this) { + case NetworkDeviceStatus.online: + return 'Online'; + case NetworkDeviceStatus.offline: + return 'Offline'; + case NetworkDeviceStatus.warning: + return 'Warning'; + case NetworkDeviceStatus.unknown: + return 'Unknown'; + } + } + + static NetworkDeviceStatus fromWire(String? value) { + switch (value) { + case 'online': + return NetworkDeviceStatus.online; + case 'offline': + return NetworkDeviceStatus.offline; + case 'warning': + return NetworkDeviceStatus.warning; + default: + return NetworkDeviceStatus.unknown; + } + } +} + enum NetworkDeviceKind { router, switchDevice, @@ -173,6 +219,7 @@ class NetworkDevice extends OfflineFirstWithSupabaseModel { final String? mac; final String? locationId; final NetworkImportSource importSource; + final NetworkDeviceStatus status; final String? notes; final DateTime createdAt; final DateTime updatedAt; @@ -189,6 +236,7 @@ class NetworkDevice extends OfflineFirstWithSupabaseModel { this.mac, this.locationId, this.importSource = NetworkImportSource.manual, + this.status = NetworkDeviceStatus.unknown, this.notes, required this.createdAt, required this.updatedAt, @@ -207,6 +255,7 @@ class NetworkDevice extends OfflineFirstWithSupabaseModel { mac: map['mac']?.toString(), locationId: map['location_id'] as String?, importSource: NetworkImportSource.fromWire(map['import_source'] as String?), + status: NetworkDeviceStatus.fromWire(map['status'] as String?), notes: map['notes'] as String?, createdAt: DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(), @@ -226,6 +275,7 @@ class NetworkDevice extends OfflineFirstWithSupabaseModel { if (mac != null) 'mac': mac, if (locationId != null) 'location_id': locationId, 'import_source': importSource.wire, + 'status': status.wire, if (notes != null) 'notes': notes, }; @@ -239,6 +289,7 @@ class NetworkDevice extends OfflineFirstWithSupabaseModel { 'mgmt_ip': mgmtIp, 'mac': mac, 'location_id': locationId, + 'status': status.wire, 'notes': notes, }; @@ -252,6 +303,7 @@ class NetworkDevice extends OfflineFirstWithSupabaseModel { String? mgmtIp, String? mac, String? locationId, + NetworkDeviceStatus? status, String? notes, }) { return NetworkDevice( @@ -266,6 +318,7 @@ class NetworkDevice extends OfflineFirstWithSupabaseModel { mac: mac ?? this.mac, locationId: locationId ?? this.locationId, importSource: importSource, + status: status ?? this.status, notes: notes ?? this.notes, createdAt: createdAt, updatedAt: updatedAt, diff --git a/lib/providers/network_map/network_devices_provider.dart b/lib/providers/network_map/network_devices_provider.dart index 6e17ace1..29b15e1b 100644 --- a/lib/providers/network_map/network_devices_provider.dart +++ b/lib/providers/network_map/network_devices_provider.dart @@ -78,6 +78,7 @@ class NetworkDevicesController { String? mac, String? locationId, NetworkImportSource importSource = NetworkImportSource.manual, + NetworkDeviceStatus status = NetworkDeviceStatus.unknown, String? notes, }) async { final client = ref.read(supabaseClientProvider); @@ -94,6 +95,7 @@ class NetworkDevicesController { 'mac': ?mac, 'location_id': ?locationId, 'import_source': importSource.wire, + 'status': status.wire, 'notes': ?notes, }) .select() diff --git a/lib/screens/network_map/network_map_device_edit_screen.dart b/lib/screens/network_map/network_map_device_edit_screen.dart index eed9f5d9..26ec3366 100644 --- a/lib/screens/network_map/network_map_device_edit_screen.dart +++ b/lib/screens/network_map/network_map_device_edit_screen.dart @@ -39,6 +39,7 @@ class _NetworkMapDeviceEditScreenState final _notesCtrl = TextEditingController(); NetworkDeviceKind _kind = NetworkDeviceKind.switchDevice; NetworkDeviceRole? _role; + NetworkDeviceStatus _status = NetworkDeviceStatus.unknown; String? _siteId; String? _locationId; bool _saving = false; @@ -67,7 +68,7 @@ class _NetworkMapDeviceEditScreenState _notesCtrl.text = d.notes ?? ''; _kind = d.kind; _role = d.role; - // Resolve the device's location → its site for the pickers. + _status = d.status; if (d.locationId != null) { _locationId = d.locationId; for (final l in locations) { @@ -179,6 +180,7 @@ class _NetworkMapDeviceEditScreenState name: _nameCtrl.text.trim(), kind: _kind, role: _role, + status: _status, vendor: _vendorCtrl.text.trim().isEmpty ? null : _vendorCtrl.text.trim(), model: _modelCtrl.text.trim().isEmpty ? null : _modelCtrl.text.trim(), serial: _serialCtrl.text.trim().isEmpty ? null : _serialCtrl.text.trim(), @@ -195,6 +197,7 @@ class _NetworkMapDeviceEditScreenState name: _nameCtrl.text.trim(), kind: _kind, role: _role, + status: _status, vendor: _vendorCtrl.text.trim().isEmpty ? null : _vendorCtrl.text.trim(), model: _modelCtrl.text.trim().isEmpty ? null : _modelCtrl.text.trim(), serial: _serialCtrl.text.trim().isEmpty ? null : _serialCtrl.text.trim(), @@ -219,6 +222,20 @@ class _NetworkMapDeviceEditScreenState ); } + Icon _statusIcon(BuildContext context) { + final cs = Theme.of(context).colorScheme; + switch (_status) { + case NetworkDeviceStatus.online: + return const Icon(Icons.circle, size: 14, color: Color(0xFF388E3C)); + case NetworkDeviceStatus.offline: + return Icon(Icons.circle, size: 14, color: cs.error); + case NetworkDeviceStatus.warning: + return const Icon(Icons.circle, size: 14, color: Color(0xFFF57C00)); + case NetworkDeviceStatus.unknown: + return Icon(Icons.circle_outlined, size: 14, color: cs.onSurfaceVariant); + } + } + @override Widget build(BuildContext context) { final existingAsync = widget.deviceId == null @@ -227,8 +244,6 @@ class _NetworkMapDeviceEditScreenState final sitesAsync = ref.watch(networkSitesProvider); final locationsAsync = ref.watch(networkLocationsProvider); - // For a new device under a specific site (from the /site/:id/device/new - // route), pre-fill the site picker so the user can save without picking. if (!_initialized && widget.deviceId == null && widget.initialSiteId != null) { _siteId = widget.initialSiteId; } @@ -241,9 +256,18 @@ class _NetworkMapDeviceEditScreenState ), title: Text(widget.deviceId == null ? 'New device' : 'Edit device'), actions: [ - TextButton( - onPressed: _saving ? null : _save, - child: _saving ? const Text('Saving…') : const Text('Save'), + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), ), ], ), @@ -262,155 +286,203 @@ class _NetworkMapDeviceEditScreenState child: Form( key: _formKey, child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextFormField( - controller: _nameCtrl, - decoration: const InputDecoration(labelText: 'Name *'), - validator: (v) => (v == null || v.trim().isEmpty) - ? 'Name is required' - : null, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _kind, - items: NetworkDeviceKind.values - .map((k) => - DropdownMenuItem(value: k, child: Text(k.label))) - .toList(), - onChanged: (v) => setState(() => _kind = v ?? _kind), - decoration: const InputDecoration(labelText: 'Type *'), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _role, - items: [ - const DropdownMenuItem( - value: null, child: Text('(none)')), - for (final r in NetworkDeviceRole.values) - DropdownMenuItem(value: r, child: Text(r.label)), - ], - onChanged: (v) => setState(() => _role = v), - decoration: - const InputDecoration(labelText: 'Logical role'), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _siteId, - items: [ - const DropdownMenuItem( - value: null, child: Text('Unassigned')), - for (final s in sitesAsync.valueOrNull ?? []) - DropdownMenuItem(value: s.id, child: Text(s.name)), - ], - onChanged: (v) => setState(() { - _siteId = v; - // Reset location when site changes — old location wouldn't - // belong to the new site. - _locationId = null; - }), - decoration: const InputDecoration( - labelText: 'Site', - helperText: - 'Devices must be assigned to a site to appear on the topology canvas.', - ), - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: _locationId, - items: [ - const DropdownMenuItem( - value: null, - child: Text('(Default — auto-create "Main")'), - ), - for (final l in locationsForSite) - DropdownMenuItem( - value: l.id, - child: Text( - '${l.name} · ${_locationKindLabel(l.kind)}', - ), + // ── Identity ──────────────────────────────────────────── + _SectionCard( + icon: Icons.badge_outlined, + title: 'Identity', + children: [ + TextFormField( + controller: _nameCtrl, + autofocus: widget.deviceId == null, + decoration: const InputDecoration(labelText: 'Name *'), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Name is required' + : null, + ), + const SizedBox(height: 12), + _FieldRow( + left: DropdownButtonFormField( + initialValue: _kind, + items: NetworkDeviceKind.values + .map((k) => DropdownMenuItem( + value: k, + child: Text(k.label), + )) + .toList(), + onChanged: (v) => setState(() => _kind = v ?? _kind), + decoration: const InputDecoration(labelText: 'Type *'), + ), + right: DropdownButtonFormField( + initialValue: _role, + items: [ + const DropdownMenuItem( + value: null, + child: Text('(none)'), ), - ], - onChanged: _siteId == null - ? null - : (v) => setState(() => _locationId = v), - decoration: InputDecoration( - labelText: 'Location', - helperText: _siteId == null - ? 'Pick a site first.' - : 'Specific building / floor / room / rack within the site.', - enabled: _siteId != null, + for (final r in NetworkDeviceRole.values) + DropdownMenuItem(value: r, child: Text(r.label)), + ], + onChanged: (v) => setState(() => _role = v), + decoration: const InputDecoration(labelText: 'Role'), ), ), - ), - const SizedBox(width: 8), - IconButton.filledTonal( - tooltip: 'Add new location to this site', - onPressed: _siteId == null - ? null - : _showAddLocationDialog, - icon: const Icon(Icons.add_location_alt_outlined), - ), - ], - ), - const SizedBox(height: 12), - TextFormField( - controller: _vendorCtrl, - decoration: const InputDecoration( - labelText: 'Vendor (e.g. Ruijie, TP-Link)', - ), - ), - const SizedBox(height: 12), - TextFormField( - controller: _modelCtrl, - decoration: const InputDecoration(labelText: 'Model'), - ), - const SizedBox(height: 12), - TextFormField( - controller: _serialCtrl, - decoration: const InputDecoration(labelText: 'Serial'), - ), - const SizedBox(height: 12), - TextFormField( - controller: _mgmtIpCtrl, - decoration: - const InputDecoration(labelText: 'Management IP'), - keyboardType: TextInputType.text, - ), - const SizedBox(height: 12), - TextFormField( - controller: _macCtrl, - decoration: const InputDecoration(labelText: 'MAC address'), - ), - const SizedBox(height: 12), - TextFormField( - controller: _notesCtrl, - decoration: const InputDecoration(labelText: 'Notes'), - maxLines: 3, - ), - if (widget.deviceId != null) ...[ - const SizedBox(height: 24), - Row( - children: [ - Text('Ports', - style: Theme.of(context).textTheme.titleMedium), - const Spacer(), - TextButton.icon( - onPressed: () => _addPort(widget.deviceId!), - icon: const Icon(Icons.add), - label: const Text('Add port'), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _status, + items: NetworkDeviceStatus.values + .map((s) => DropdownMenuItem( + value: s, + child: Text(s.label), + )) + .toList(), + onChanged: (v) => setState( + () => _status = v ?? NetworkDeviceStatus.unknown), + decoration: InputDecoration( + labelText: 'Status', + prefixIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: _statusIcon(context), + ), + prefixIconConstraints: + const BoxConstraints(minWidth: 0, minHeight: 0), + ), ), ], ), - _PortsList(deviceId: widget.deviceId!), + const SizedBox(height: 16), + + // ── Location ───────────────────────────────────────────── + _SectionCard( + icon: Icons.location_on_outlined, + title: 'Location', + children: [ + DropdownButtonFormField( + initialValue: _siteId, + items: [ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + for (final s in sitesAsync.valueOrNull ?? []) + DropdownMenuItem(value: s.id, child: Text(s.name)), + ], + onChanged: (v) => setState(() { + _siteId = v; + _locationId = null; + }), + decoration: const InputDecoration( + labelText: 'Site', + helperText: + 'Devices must be assigned to a site to appear on the topology canvas.', + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: _locationId, + items: [ + const DropdownMenuItem( + value: null, + child: Text('(Default — auto-create "Main")'), + ), + for (final l in locationsForSite) + DropdownMenuItem( + value: l.id, + child: Text( + '${l.name} · ${_locationKindLabel(l.kind)}', + ), + ), + ], + onChanged: _siteId == null + ? null + : (v) => setState(() => _locationId = v), + decoration: InputDecoration( + labelText: 'Location', + helperText: _siteId == null + ? 'Pick a site first.' + : 'Building / floor / room / rack within the site.', + enabled: _siteId != null, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: 'Add new location to this site', + onPressed: + _siteId == null ? null : _showAddLocationDialog, + icon: const Icon(Icons.add_location_alt_outlined), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + + // ── Hardware Details ───────────────────────────────────── + _SectionCard( + icon: Icons.memory_outlined, + title: 'Hardware Details', + children: [ + _FieldRow( + left: TextFormField( + controller: _vendorCtrl, + decoration: const InputDecoration( + labelText: 'Vendor', + hintText: 'e.g. Ruijie, TP-Link', + ), + ), + right: TextFormField( + controller: _modelCtrl, + decoration: const InputDecoration(labelText: 'Model'), + ), + ), + const SizedBox(height: 12), + _FieldRow( + left: TextFormField( + controller: _serialCtrl, + decoration: + const InputDecoration(labelText: 'Serial'), + ), + right: TextFormField( + controller: _mgmtIpCtrl, + decoration: const InputDecoration( + labelText: 'Management IP'), + keyboardType: TextInputType.text, + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _macCtrl, + decoration: + const InputDecoration(labelText: 'MAC address'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _notesCtrl, + decoration: const InputDecoration(labelText: 'Notes'), + maxLines: 4, + ), + ], + ), + + // ── Ports (edit mode only) ──────────────────────────────── + if (widget.deviceId != null) ...[ + const SizedBox(height: 16), + _PortsSectionCard( + deviceId: widget.deviceId!, + onAddPort: () => _addPort(widget.deviceId!), + ), + ], + + const SizedBox(height: 24), ], - ], ), ), ), @@ -421,12 +493,138 @@ class _NetworkMapDeviceEditScreenState } } +// ── Helper widgets ──────────────────────────────────────────────────────────── + +/// Outlined card with a section header (icon + title) and field children. +class _SectionCard extends StatelessWidget { + const _SectionCard({ + required this.icon, + required this.title, + required this.children, + }); + + final IconData icon; + final String title; + final List children; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + return Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: cs.primary), + const SizedBox(width: 8), + Text( + title, + style: tt.labelLarge?.copyWith(color: cs.primary), + ), + ], + ), + const SizedBox(height: 16), + ...children, + ], + ), + ), + ); + } +} + +/// Renders two widgets side-by-side on wide screens (≥480dp), stacked on narrow. +class _FieldRow extends StatelessWidget { + const _FieldRow({ + required this.left, + required this.right, + }); + + final Widget left; + final Widget right; + static const double threshold = 480; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= threshold) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: left), + const SizedBox(width: 12), + Expanded(child: right), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + left, + const SizedBox(height: 12), + right, + ], + ); + }, + ); + } +} + +/// Ports section card with header and add-port button. +class _PortsSectionCard extends StatelessWidget { + const _PortsSectionCard({ + required this.deviceId, + required this.onAddPort, + }); + + final String deviceId; + final VoidCallback onAddPort; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + return Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.cable_outlined, size: 16, color: cs.primary), + const SizedBox(width: 8), + Text( + 'Ports', + style: tt.labelLarge?.copyWith(color: cs.primary), + ), + const Spacer(), + FilledButton.tonal( + onPressed: onAddPort, + child: const Text('Add port'), + ), + ], + ), + const SizedBox(height: 8), + _PortsList(deviceId: deviceId), + ], + ), + ), + ); + } +} + class _PortsList extends ConsumerWidget { const _PortsList({required this.deviceId}); final String deviceId; @override Widget build(BuildContext context, WidgetRef ref) { + final cs = Theme.of(context).colorScheme; final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId)); return portsAsync.when( loading: () => const Padding( @@ -436,9 +634,19 @@ class _PortsList extends ConsumerWidget { error: (e, _) => Text('Error: $e'), data: (ports) { if (ports.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Text('No ports yet.'), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + Icon(Icons.cable_outlined, + size: 32, color: cs.onSurfaceVariant), + const SizedBox(height: 8), + Text( + 'No ports configured yet.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ], + ), ); } return Column( diff --git a/lib/screens/network_map/network_map_device_screen.dart b/lib/screens/network_map/network_map_device_screen.dart index e0e87f75..66e99cd9 100644 --- a/lib/screens/network_map/network_map_device_screen.dart +++ b/lib/screens/network_map/network_map_device_screen.dart @@ -7,6 +7,7 @@ import '../../providers/network_map/network_devices_provider.dart'; import '../../providers/profile_provider.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/responsive_body.dart'; +import 'widgets/link_edit_dialog.dart'; import 'widgets/port_edit_dialog.dart'; import 'widgets/port_list_tile.dart'; @@ -98,6 +99,13 @@ class NetworkMapDeviceScreen extends ConsumerWidget { return null; } + String? linkIdForPort(String portId) { + for (final link in links) { + if (link.portA == portId || link.portB == portId) return link.id; + } + return null; + } + return ResponsiveBody( maxWidth: 720, child: ListView( @@ -131,6 +139,29 @@ class NetworkMapDeviceScreen extends ConsumerWidget { existing: port, ) : null, + onConnect: canEdit + ? (ctx) => showLinkEditDialog( + context: ctx, + ref: ref, + portId: port.id, + currentDeviceId: device.id, + ) + : null, + onDisconnect: canEdit + ? (_) { + final lid = linkIdForPort(port.id); + if (lid != null) { + ref + .read(networkDevicesControllerProvider) + .deleteLink(lid); + } + } + : null, + onDelete: canEdit + ? () => ref + .read(networkDevicesControllerProvider) + .deletePort(port.id) + : null, ), if (device.notes != null && device.notes!.trim().isNotEmpty) ...[ Padding( diff --git a/lib/screens/network_map/network_map_site_screen.dart b/lib/screens/network_map/network_map_site_screen.dart index 96801a53..dba42e6c 100644 --- a/lib/screens/network_map/network_map_site_screen.dart +++ b/lib/screens/network_map/network_map_site_screen.dart @@ -8,15 +8,48 @@ import '../../providers/network_map/network_topology_provider.dart'; import '../../providers/profile_provider.dart'; import '../../widgets/app_state_view.dart'; import 'widgets/topology_canvas.dart'; +import 'widgets/topology_legend.dart'; +import 'widgets/topology_minimap.dart'; +import 'widgets/zoom_controls.dart'; -class NetworkMapSiteScreen extends ConsumerWidget { +class NetworkMapSiteScreen extends ConsumerStatefulWidget { const NetworkMapSiteScreen({super.key, required this.siteId}); final String siteId; @override - Widget build(BuildContext context, WidgetRef ref) { - final topologyAsync = ref.watch(topologyForSiteProvider(siteId)); + ConsumerState createState() => + _NetworkMapSiteScreenState(); +} + +class _NetworkMapSiteScreenState extends ConsumerState { + late final TransformationController _transformController; + Map _nodePositions = const {}; + Size _canvasSize = const Size(600, 400); + + @override + void initState() { + super.initState(); + _transformController = TransformationController(); + } + + @override + void dispose() { + _transformController.dispose(); + super.dispose(); + } + + void _onLayoutUpdated(Map positions, Size canvasSize) { + if (!mounted) return; + setState(() { + _nodePositions = positions; + _canvasSize = canvasSize; + }); + } + + @override + Widget build(BuildContext context) { + final topologyAsync = ref.watch(topologyForSiteProvider(widget.siteId)); final viewMode = ref.watch(topologyViewModeProvider); final sitesAsync = ref.watch(networkSitesProvider); final profileAsync = ref.watch(currentProfileProvider); @@ -27,7 +60,7 @@ class NetworkMapSiteScreen extends ConsumerWidget { final siteName = sitesAsync.valueOrNull ?.firstWhere( - (s) => s.id == siteId, + (s) => s.id == widget.siteId, orElse: () => sitesAsync.valueOrNull!.first, ) .name; @@ -64,7 +97,7 @@ class NetworkMapSiteScreen extends ConsumerWidget { tooltip: 'Add device', icon: const Icon(Icons.add_circle_outline), onPressed: () => - context.go('/network-map/site/$siteId/device/new'), + context.go('/network-map/site/${widget.siteId}/device/new'), ), ], ), @@ -72,15 +105,97 @@ class NetworkMapSiteScreen extends ConsumerWidget { loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => AppErrorView( error: e, - onRetry: () => ref.invalidate(topologyForSiteProvider(siteId)), + onRetry: () => ref.invalidate(topologyForSiteProvider(widget.siteId)), ), - data: (graph) => TopologyCanvas( + data: (graph) => _CanvasWithOverlays( graph: graph, viewMode: viewMode, - onDeviceTap: (device) => - context.go('/network-map/device/${device.id}'), + siteId: widget.siteId, + transformController: _transformController, + nodePositions: _nodePositions, + canvasSize: _canvasSize, + onLayoutUpdated: _onLayoutUpdated, ), ), ); } } + +/// Canvas + all overlay widgets (legend, zoom controls, minimap) in a Stack. +class _CanvasWithOverlays extends StatelessWidget { + const _CanvasWithOverlays({ + required this.graph, + required this.viewMode, + required this.siteId, + required this.transformController, + required this.nodePositions, + required this.canvasSize, + required this.onLayoutUpdated, + }); + + final TopologyGraph graph; + final TopologyViewMode viewMode; + final String siteId; + final TransformationController transformController; + final Map nodePositions; + final Size canvasSize; + final void Function(Map, Size) onLayoutUpdated; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final viewportSize = Size(constraints.maxWidth, constraints.maxHeight); + + return Stack( + children: [ + // ── Main topology canvas ───────────────────────────────────── + Positioned.fill( + child: TopologyCanvas( + graph: graph, + viewMode: viewMode, + transformationController: transformController, + onLayoutUpdated: onLayoutUpdated, + onDeviceTap: (device) => + context.push('/network-map/device/${device.id}'), + ), + ), + + // ── Legend — bottom-left ───────────────────────────────────── + Positioned( + left: 16, + bottom: 16, + child: const TopologyLegend(), + ), + + // ── Zoom controls + minimap — bottom-right ─────────────────── + Positioned( + right: 16, + bottom: 16, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Minimap above zoom controls + TopologyMinimap( + graph: graph, + nodePositions: nodePositions, + canvasSize: canvasSize, + controller: transformController, + viewportSize: viewportSize, + ), + const SizedBox(height: 8), + ZoomControls( + controller: transformController, + canvasSize: canvasSize, + viewportSize: viewportSize, + ), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/screens/network_map/widgets/device_node.dart b/lib/screens/network_map/widgets/device_node.dart index 104d3e58..af59b244 100644 --- a/lib/screens/network_map/widgets/device_node.dart +++ b/lib/screens/network_map/widgets/device_node.dart @@ -1,17 +1,12 @@ import 'package:flutter/material.dart'; import '../../../models/network/network_device.dart'; +import 'network_device_icon.dart'; /// A styled node representing one network device on the topology canvas. /// -/// Designed to feel solid at ~120-160dp wide so it remains legible at the -/// "good for 200 nodes" zoom level called out in the V1 spec. -/// -/// The node has three visual states beyond its default rest: -/// - [isSelected]: stronger primary-tinted background + 2dp primary border. -/// - [isHighlighted]: softer secondary tint + primary outline, used when an -/// edge touching this device is hovered on the canvas. -/// - [isSelected] takes precedence over [isHighlighted]. +/// Accepts an explicit [nodeSize] so role-based sizing can be applied by the +/// canvas without this widget needing to know the layout strategy. class DeviceNode extends StatelessWidget { const DeviceNode({ super.key, @@ -19,6 +14,7 @@ class DeviceNode extends StatelessWidget { this.portCount, this.isSelected = false, this.isHighlighted = false, + this.nodeSize = const Size(160, 110), this.onTap, }); @@ -26,29 +22,9 @@ class DeviceNode extends StatelessWidget { final int? portCount; final bool isSelected; final bool isHighlighted; + final Size nodeSize; final VoidCallback? onTap; - IconData _iconForKind(NetworkDeviceKind kind) { - switch (kind) { - case NetworkDeviceKind.router: - return Icons.router_outlined; - case NetworkDeviceKind.switchDevice: - return Icons.hub_outlined; - case NetworkDeviceKind.ap: - return Icons.wifi_outlined; - case NetworkDeviceKind.firewall: - return Icons.security_outlined; - case NetworkDeviceKind.server: - return Icons.dns_outlined; - case NetworkDeviceKind.endpoint: - return Icons.devices_outlined; - case NetworkDeviceKind.patchPanel: - return Icons.view_module_outlined; - case NetworkDeviceKind.other: - return Icons.device_unknown_outlined; - } - } - Color _accentForRole(ColorScheme cs, NetworkDeviceRole? role) { switch (role) { case NetworkDeviceRole.core: @@ -65,11 +41,25 @@ class DeviceNode extends StatelessWidget { } } + Color? _statusColor(ColorScheme cs, NetworkDeviceStatus status) { + switch (status) { + case NetworkDeviceStatus.online: + return const Color(0xFF34A853); // Google green — universally understood + case NetworkDeviceStatus.offline: + return cs.error; + case NetworkDeviceStatus.warning: + return const Color(0xFFFBBC04); // Amber + case NetworkDeviceStatus.unknown: + return null; // No dot shown + } + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final tt = Theme.of(context).textTheme; final accent = _accentForRole(cs, device.role); + final statusColor = _statusColor(cs, device.status); final bgColor = isSelected ? cs.primaryContainer @@ -105,67 +95,94 @@ class DeviceNode extends StatelessWidget { child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), - child: Container( - width: 160, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( + child: Stack( + children: [ + Container( + width: nodeSize.width, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Icon(_iconForKind(device.kind), size: 18, color: accent), - const SizedBox(width: 6), - Expanded( - child: Text( - device.kind.label, - style: tt.labelSmall?.copyWith(color: accent), + Row( + children: [ + NetworkDeviceIcon( + kind: device.kind, + color: accent, + size: 20, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + device.kind.label, + style: tt.labelSmall?.copyWith(color: accent), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + device.name, + style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (device.vendor != null || device.model != null) ...[ + const SizedBox(height: 2), + Text( + [ + if (device.vendor != null) device.vendor, + if (device.model != null) device.model, + ].whereType().join(' • '), + style: tt.labelSmall?.copyWith( + color: cs.onSurfaceVariant), maxLines: 1, overflow: TextOverflow.ellipsis, ), - ), - ], - ), - const SizedBox(height: 4), - Text( - device.name, - style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (device.vendor != null || device.model != null) ...[ - const SizedBox(height: 2), - Text( - [ - if (device.vendor != null) device.vendor, - if (device.model != null) device.model, - ].whereType().join(' • '), - style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - if (portCount != null) ...[ - const SizedBox(height: 4), - Row( - children: [ - Icon( - Icons.cable_outlined, - size: 12, - color: cs.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '$portCount ports', - style: tt.labelSmall?.copyWith( - color: cs.onSurfaceVariant, - ), + ], + if (portCount != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.cable_outlined, + size: 12, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '$portCount ports', + style: tt.labelSmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], ), ], + ], + ), + ), + // Status dot — top-right, only shown when status is not unknown + if (statusColor != null) + Positioned( + top: 8, + right: 8, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + border: Border.all( + color: bgColor, + width: 1.5, + ), + ), ), - ], - ], - ), + ), + ], ), ), ), diff --git a/lib/screens/network_map/widgets/link_edit_dialog.dart b/lib/screens/network_map/widgets/link_edit_dialog.dart new file mode 100644 index 00000000..b319ab27 --- /dev/null +++ b/lib/screens/network_map/widgets/link_edit_dialog.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/network/network_link.dart'; +import '../../../models/network/network_port.dart'; +import '../../../providers/network_map/network_devices_provider.dart'; + +/// Opens an M3 dialog to connect [portId] to a port on another device. +/// +/// Returns true if a link was successfully created. +Future showLinkEditDialog({ + required BuildContext context, + required WidgetRef ref, + required String portId, + required String currentDeviceId, +}) async { + final result = await showDialog( + context: context, + builder: (_) => _LinkEditDialog( + portId: portId, + currentDeviceId: currentDeviceId, + ), + ); + return result == true; +} + +class _LinkEditDialog extends ConsumerStatefulWidget { + const _LinkEditDialog({ + required this.portId, + required this.currentDeviceId, + }); + + final String portId; + final String currentDeviceId; + + @override + ConsumerState<_LinkEditDialog> createState() => _LinkEditDialogState(); +} + +class _LinkEditDialogState extends ConsumerState<_LinkEditDialog> { + String? _selectedDeviceId; + String? _selectedPortId; + NetworkLinkKind? _linkKind; + final _cableLabelCtrl = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _cableLabelCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + final portA = widget.portId; + final portB = _selectedPortId; + if (portB == null) return; + setState(() => _saving = true); + try { + ref.invalidate(networkLinksProvider); + final controller = ref.read(networkDevicesControllerProvider); + await controller.createLink( + portA: portA, + portB: portB, + linkKind: _linkKind, + cableLabel: _cableLabelCtrl.text.trim().isEmpty + ? null + : _cableLabelCtrl.text.trim(), + ); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to create link: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final devicesAsync = ref.watch(networkDevicesProvider); + final portsAsync = _selectedDeviceId != null + ? ref.watch(networkPortsByDeviceProvider(_selectedDeviceId!)) + : const AsyncValue>.data([]); + final allLinksAsync = ref.watch(networkLinksProvider); + + final otherDevices = (devicesAsync.valueOrNull ?? const []) + .where((d) => d.id != widget.currentDeviceId) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + + final allLinks = allLinksAsync.valueOrNull ?? const []; + final usedPortIds = { + for (final link in allLinks) ...[link.portA, link.portB], + }; + + final devicePorts = portsAsync.valueOrNull ?? const []; + final availablePorts = + devicePorts.where((p) => !usedPortIds.contains(p.id)).toList(); + + final canSave = _selectedPortId != null && !_saving; + + return AlertDialog( + title: const Text('Connect port'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _selectedDeviceId, + decoration: + const InputDecoration(labelText: 'Remote device *'), + items: [ + for (final d in otherDevices) + DropdownMenuItem(value: d.id, child: Text(d.name)), + ], + onChanged: (v) => setState(() { + _selectedDeviceId = v; + _selectedPortId = null; + }), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey(_selectedDeviceId), + initialValue: _selectedPortId, + decoration: InputDecoration( + labelText: 'Remote port *', + helperText: _selectedDeviceId == null + ? 'Select a device first' + : availablePorts.isEmpty + ? 'No available ports on this device' + : null, + ), + items: [ + for (final p in availablePorts) + DropdownMenuItem( + value: p.id, + child: Text(p.portNumber), + ), + ], + onChanged: _selectedDeviceId == null + ? null + : (v) => setState(() => _selectedPortId = v), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _linkKind, + decoration: const InputDecoration(labelText: 'Link type'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('(unknown)'), + ), + for (final k in NetworkLinkKind.values) + DropdownMenuItem(value: k, child: Text(k.label)), + ], + onChanged: (v) => setState(() => _linkKind = v), + ), + const SizedBox(height: 12), + TextField( + controller: _cableLabelCtrl, + decoration: const InputDecoration( + labelText: 'Cable label', + helperText: 'e.g. "CAT6-01", "SMF-A3" (optional)', + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: canSave ? _save : null, + child: Text(_saving ? 'Saving…' : 'Connect'), + ), + ], + ); + } +} diff --git a/lib/screens/network_map/widgets/network_device_icon.dart b/lib/screens/network_map/widgets/network_device_icon.dart new file mode 100644 index 00000000..81beb1c9 --- /dev/null +++ b/lib/screens/network_map/widgets/network_device_icon.dart @@ -0,0 +1,374 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../models/network/network_device.dart'; + +/// Custom-painted network equipment icon for each [NetworkDeviceKind]. +/// +/// Draws simplified but recognizable symbols matching professional network +/// topology diagram conventions (Cisco-style silhouettes). +class NetworkDeviceIcon extends StatelessWidget { + const NetworkDeviceIcon({ + super.key, + required this.kind, + required this.color, + this.size = 20, + }); + + final NetworkDeviceKind kind; + final Color color; + final double size; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _DeviceIconPainter(kind: kind, color: color), + ), + ); + } +} + +class _DeviceIconPainter extends CustomPainter { + _DeviceIconPainter({required this.kind, required this.color}); + + final NetworkDeviceKind kind; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + switch (kind) { + case NetworkDeviceKind.router: + _drawRouter(canvas, size); + case NetworkDeviceKind.switchDevice: + _drawSwitch(canvas, size); + case NetworkDeviceKind.ap: + _drawAp(canvas, size); + case NetworkDeviceKind.firewall: + _drawFirewall(canvas, size); + case NetworkDeviceKind.server: + _drawServer(canvas, size); + case NetworkDeviceKind.endpoint: + _drawEndpoint(canvas, size); + case NetworkDeviceKind.patchPanel: + _drawPatchPanel(canvas, size); + case NetworkDeviceKind.other: + _drawOther(canvas, size); + } + } + + Paint get _stroke => Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..strokeWidth = 1.4; + + Paint get _fill => Paint() + ..color = color + ..style = PaintingStyle.fill; + + // ─── Router: cylinder body + 4 arrows ───────────────────────────────────── + + void _drawRouter(Canvas canvas, Size s) { + final cx = s.width / 2; + final cy = s.height / 2; + final r = s.width * 0.28; + final p = _stroke; + + // Circle body + canvas.drawCircle(Offset(cx, cy), r, p); + + // 4 directional arrows at N/S/E/W + const arrowLen = 0.22; + const arrowHead = 0.1; + for (var i = 0; i < 4; i++) { + final angle = i * math.pi / 2; + final ax = cx + math.cos(angle) * (r + s.width * arrowLen); + final ay = cy + math.sin(angle) * (r + s.width * arrowLen); + final bx = cx + math.cos(angle) * (r + 1); + final by = cy + math.sin(angle) * (r + 1); + canvas.drawLine(Offset(bx, by), Offset(ax, ay), p); + // Arrowhead + final perp = angle + math.pi / 2; + final hw = s.width * arrowHead; + canvas.drawPath( + Path() + ..moveTo(ax, ay) + ..lineTo( + ax - math.cos(angle) * hw + math.cos(perp) * hw / 2, + ay - math.sin(angle) * hw + math.sin(perp) * hw / 2, + ) + ..lineTo( + ax - math.cos(angle) * hw - math.cos(perp) * hw / 2, + ay - math.sin(angle) * hw - math.sin(perp) * hw / 2, + ) + ..close(), + _fill, + ); + } + } + + // ─── Switch: rectangle + port lines ─────────────────────────────────────── + + void _drawSwitch(Canvas canvas, Size s) { + final w = s.width * 0.86; + final h = s.height * 0.42; + final left = (s.width - w) / 2; + final top = (s.height - h) / 2; + final p = _stroke; + + // Body + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left, top, w, h), + const Radius.circular(2), + ), + p, + ); + + // Port lines (8 evenly spaced) + const ports = 8; + final portSpacing = w / (ports + 1); + final portH = h * 0.5; + final portTop = top + h * 0.2; + for (var i = 1; i <= ports; i++) { + final px = left + portSpacing * i; + canvas.drawLine( + Offset(px, portTop), + Offset(px, portTop + portH), + p, + ); + } + // Two legs at bottom center + final legX1 = s.width * 0.38; + final legX2 = s.width * 0.62; + final bodyBottom = top + h; + canvas.drawLine( + Offset(legX1, bodyBottom), + Offset(legX1, s.height * 0.85), + p, + ); + canvas.drawLine( + Offset(legX2, bodyBottom), + Offset(legX2, s.height * 0.85), + p, + ); + canvas.drawLine( + Offset(legX1, s.height * 0.85), + Offset(legX2, s.height * 0.85), + p, + ); + } + + // ─── AP: semicircle waves + stem ────────────────────────────────────────── + + void _drawAp(Canvas canvas, Size s) { + final cx = s.width / 2; + final base = s.height * 0.72; + final p = _stroke; + + // Stem + canvas.drawLine(Offset(cx, base), Offset(cx, s.height * 0.88), p); + canvas.drawLine( + Offset(cx - s.width * 0.15, s.height * 0.88), + Offset(cx + s.width * 0.15, s.height * 0.88), + p, + ); + + // 3 concentric arcs from narrow to wide + for (var i = 1; i <= 3; i++) { + final r = s.width * 0.13 * i; + final rect = Rect.fromCircle(center: Offset(cx, base), radius: r); + canvas.drawArc(rect, math.pi, -math.pi, false, p); + } + } + + // ─── Firewall: brick-wall grid ───────────────────────────────────────────── + + void _drawFirewall(Canvas canvas, Size s) { + final w = s.width * 0.8; + final h = s.height * 0.72; + final left = (s.width - w) / 2; + final top = (s.height - h) / 2; + final p = _stroke; + + // Outer rect + canvas.drawRect(Rect.fromLTWH(left, top, w, h), p); + + // 3 rows, 2 staggered bricks each + final rowH = h / 3; + for (var row = 0; row < 3; row++) { + final rowTop = top + row * rowH; + // horizontal mortar line + if (row > 0) { + canvas.drawLine( + Offset(left, rowTop), + Offset(left + w, rowTop), + p, + ); + } + // vertical mortar — offset every other row by half a brick + final offset = (row % 2 == 0) ? w / 2 : w / 4; + canvas.drawLine( + Offset(left + offset, rowTop), + Offset(left + offset, rowTop + rowH), + p, + ); + } + } + + // ─── Server: rack unit with LED dots ────────────────────────────────────── + + void _drawServer(Canvas canvas, Size s) { + final w = s.width * 0.72; + final h = s.height * 0.78; + final left = (s.width - w) / 2; + final top = (s.height - h) / 2; + final p = _stroke; + final fp = _fill; + + // Body + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left, top, w, h), + const Radius.circular(2), + ), + p, + ); + + // 3 horizontal rack-unit dividers + LED dots + final unitH = h / 3; + for (var i = 0; i < 3; i++) { + final unitTop = top + i * unitH; + if (i > 0) { + canvas.drawLine( + Offset(left, unitTop), + Offset(left + w, unitTop), + p, + ); + } + // Small LED dot on right side of each unit + canvas.drawCircle( + Offset(left + w - w * 0.15, unitTop + unitH / 2), + s.width * 0.05, + fp, + ); + } + } + + // ─── Endpoint: monitor + stand ───────────────────────────────────────────── + + void _drawEndpoint(Canvas canvas, Size s) { + final monW = s.width * 0.76; + final monH = s.height * 0.52; + final left = (s.width - monW) / 2; + final top = s.height * 0.08; + final p = _stroke; + + // Monitor bezel + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left, top, monW, monH), + const Radius.circular(2), + ), + p, + ); + + // Screen inner (inset 2dp) + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left + 3, top + 3, monW - 6, monH - 6), + const Radius.circular(1), + ), + p, + ); + + // Neck + final neckX = s.width / 2; + final neckTop = top + monH; + canvas.drawLine( + Offset(neckX, neckTop), + Offset(neckX, neckTop + s.height * 0.18), + p, + ); + + // Base + canvas.drawLine( + Offset(s.width * 0.25, s.height * 0.88), + Offset(s.width * 0.75, s.height * 0.88), + p, + ); + } + + // ─── Patch panel: flat rect with 2 rows of port circles ────────────────── + + void _drawPatchPanel(Canvas canvas, Size s) { + final w = s.width * 0.9; + final h = s.height * 0.44; + final left = (s.width - w) / 2; + final top = (s.height - h) / 2; + final p = _stroke; + + // Body + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left, top, w, h), + const Radius.circular(2), + ), + p, + ); + + // 2 rows × 5 port circles + const cols = 5; + const rows = 2; + final colSpacing = w / (cols + 1); + final rowSpacing = h / (rows + 1); + final dotR = s.width * 0.04; + for (var row = 1; row <= rows; row++) { + for (var col = 1; col <= cols; col++) { + canvas.drawCircle( + Offset(left + colSpacing * col, top + rowSpacing * row), + dotR, + p, + ); + } + } + } + + // ─── Other: circle with question mark ───────────────────────────────────── + + void _drawOther(Canvas canvas, Size s) { + final cx = s.width / 2; + final cy = s.height / 2; + final r = s.width * 0.42; + final p = _stroke; + + canvas.drawCircle(Offset(cx, cy), r, p); + + // "?" drawn as a small arc + dot + final tp = TextPainter( + text: TextSpan( + text: '?', + style: TextStyle( + fontSize: s.width * 0.52, + fontWeight: FontWeight.w700, + color: color, + height: 1, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + Offset(cx - tp.width / 2, cy - tp.height / 2), + ); + } + + @override + bool shouldRepaint(covariant _DeviceIconPainter old) => + old.kind != kind || old.color != color; +} diff --git a/lib/screens/network_map/widgets/port_list_tile.dart b/lib/screens/network_map/widgets/port_list_tile.dart index b93439e8..a8735d93 100644 --- a/lib/screens/network_map/widgets/port_list_tile.dart +++ b/lib/screens/network_map/widgets/port_list_tile.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../../../models/network/network_port.dart'; +enum _PortAction { editPort, connect, disconnect, deletePort } + class PortListTile extends StatelessWidget { const PortListTile({ super.key, @@ -10,6 +12,8 @@ class PortListTile extends StatelessWidget { this.linkedPortLabel, this.canEdit = false, this.onTap, + this.onConnect, + this.onDisconnect, this.onDelete, }); @@ -18,6 +22,8 @@ class PortListTile extends StatelessWidget { final String? linkedPortLabel; final bool canEdit; final VoidCallback? onTap; + final void Function(BuildContext)? onConnect; + final void Function(BuildContext)? onDisconnect; final VoidCallback? onDelete; @override @@ -64,10 +70,56 @@ class PortListTile extends StatelessWidget { ], ), trailing: canEdit - ? IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: 'Delete port', - onPressed: onDelete, + ? PopupMenuButton<_PortAction>( + tooltip: 'Port actions', + onSelected: (action) { + switch (action) { + case _PortAction.editPort: + onTap?.call(); + case _PortAction.connect: + onConnect?.call(context); + case _PortAction.disconnect: + onDisconnect?.call(context); + case _PortAction.deletePort: + onDelete?.call(); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: _PortAction.editPort, + child: ListTile( + leading: Icon(Icons.edit_outlined), + title: Text('Edit port'), + contentPadding: EdgeInsets.zero, + ), + ), + if (!isLinked) + const PopupMenuItem( + value: _PortAction.connect, + child: ListTile( + leading: Icon(Icons.add_link), + title: Text('Connect to…'), + contentPadding: EdgeInsets.zero, + ), + ), + if (isLinked) + const PopupMenuItem( + value: _PortAction.disconnect, + child: ListTile( + leading: Icon(Icons.link_off), + title: Text('Disconnect'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem( + value: _PortAction.deletePort, + child: ListTile( + leading: Icon(Icons.delete_outline), + title: Text('Delete port'), + contentPadding: EdgeInsets.zero, + ), + ), + ], ) : null, ); diff --git a/lib/screens/network_map/widgets/topology_canvas.dart b/lib/screens/network_map/widgets/topology_canvas.dart index ea789712..6a2f6a59 100644 --- a/lib/screens/network_map/widgets/topology_canvas.dart +++ b/lib/screens/network_map/widgets/topology_canvas.dart @@ -3,62 +3,54 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../models/network/network_device.dart'; +import '../../../models/network/network_link.dart'; import '../../../models/network/topology_graph.dart'; import '../layout/sugiyama_layout.dart'; import 'device_node.dart'; /// Pan + zoom topology canvas with Sugiyama-layered layout, M3-styled edges, -/// line-crossing jumps, port labels, multi-layer Bézier routing, and animated -/// transitions between view modes. -/// -/// Layout pipeline: -/// 1. On graph/viewMode change, [computeSugiyamaLayout] produces node -/// positions and edge waypoints. Positions are saved as a "target." -/// 2. An [AnimationController] interpolates from the previous positions to -/// the target over 600ms with [Curves.easeInOutCubicEmphasized]. -/// 3. Each frame during the transition, edge geometries and line-jump -/// crossing points are recomputed from the interpolated positions. -/// O(E²) per frame is acceptable for ≤500 edges. -/// -/// Rendering: -/// * Edges with no waypoints (single-layer span) draw as straight lines. -/// * Edges with waypoints (multi-layer span) draw as sequential quadratic -/// Bézier curves passing through each waypoint. The virtual nodes -/// informing those waypoints are never drawn as visible widgets. -/// * Line-jump arcs at crossings apply only to straight segments. -/// -/// Performance: -/// * Hover state on two [ValueNotifier]s; the painter is wrapped in a -/// [RepaintBoundary] so hover only repaints the painter, not the widget -/// tree. -/// * Pan/zoom is pure Matrix4 transform via [InteractiveViewer]; no rebuild. +/// line-crossing jumps (all edge-type combinations), link-type colours, data- +/// flow dot animation, port-label chips, multi-layer Bézier routing, role- +/// based node sizing, and animated transitions between view modes. class TopologyCanvas extends StatefulWidget { const TopologyCanvas({ super.key, required this.graph, required this.viewMode, this.selectedDeviceId, + this.transformationController, this.onDeviceTap, + this.onLayoutUpdated, }); final TopologyGraph graph; final TopologyViewMode viewMode; final String? selectedDeviceId; + final TransformationController? transformationController; final void Function(NetworkDevice device)? onDeviceTap; + /// Called whenever layout positions are recomputed (initial + re-layout). + /// Provides the final node top-left positions and total canvas size so the + /// minimap can stay in sync without reading canvas internals. + final void Function(Map positions, Size canvasSize)? + onLayoutUpdated; + @override State createState() => _TopologyCanvasState(); } class _TopologyCanvasState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { // ─── Hover state ───────────────────────────────────────────────────────── final ValueNotifier _hoveredEdgeId = ValueNotifier(null); final ValueNotifier _hoveredDeviceId = ValueNotifier(null); - // ─── Animation ─────────────────────────────────────────────────────────── + // ─── Layout animation ───────────────────────────────────────────────────── late AnimationController _layoutAnim; + // ─── Data-flow dot animation ────────────────────────────────────────────── + late AnimationController _flowAnim; + // ─── Layout snapshots (for interpolation) ──────────────────────────────── Map _prevPositions = const {}; Map _targetPositions = const {}; @@ -68,9 +60,25 @@ class _TopologyCanvasState extends State Map> _deviceToEdges = const {}; // ─── Constants ─────────────────────────────────────────────────────────── - static const Size _nodeSize = Size(160, 110); static const double _hitTolerance = 8; static const double _jumpRadius = 6; + static const int _bezierSamples = 20; + + static Size _roleSizeFor(NetworkDeviceRole? role) { + switch (role) { + case NetworkDeviceRole.core: + return const Size(180, 120); + case NetworkDeviceRole.distribution: + return const Size(170, 115); + case NetworkDeviceRole.access: + return const Size(160, 110); + case NetworkDeviceRole.edge: + return const Size(150, 100); + case NetworkDeviceRole.endpoint: + case null: + return const Size(140, 90); + } + } @override void initState() { @@ -79,6 +87,10 @@ class _TopologyCanvasState extends State vsync: this, duration: const Duration(milliseconds: 600), ); + _flowAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2400), + )..repeat(); _runLayout(animate: false); } @@ -96,13 +108,13 @@ class _TopologyCanvasState extends State _hoveredEdgeId.dispose(); _hoveredDeviceId.dispose(); _layoutAnim.dispose(); + _flowAnim.dispose(); super.dispose(); } // ─── Layout orchestration ──────────────────────────────────────────────── void _runLayout({required bool animate}) { - // Snapshot current interpolated positions BEFORE recomputing target. final newPrev = animate ? Map.from(_currentPositions()) : {}; @@ -129,6 +141,13 @@ class _TopologyCanvasState extends State } else { _layoutAnim.value = 1.0; } + + // Notify minimap with final target positions immediately (no need to wait + // for the animation to settle — the minimap shows topology structure, not + // animated intermediate state). + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onLayoutUpdated?.call(_targetPositions, _canvasSize); + }); } SugiyamaInput _buildSugiyamaInput() { @@ -151,16 +170,18 @@ class _TopologyCanvasState extends State } } } - // Physical view: no preassignment; the algorithm derives layers from - // graph structure via longest-path. We could supply location depth as a - // hint here in a future iteration. + + // Per-node sizes for Sugiyama compaction + final nodeSizes = {}; + for (final node in widget.graph.nodes) { + nodeSizes[node.device.id] = _roleSizeFor(node.device.role); + } return SugiyamaInput( nodeIds: nodeIds, edges: edges, preassignedLayers: preassigned, - // Sizes default for now; future: pass per-device width if cards become - // variable-width. + nodeSizes: nodeSizes, ); } @@ -181,9 +202,6 @@ class _TopologyCanvasState extends State // ─── Interpolation ─────────────────────────────────────────────────────── - /// Node positions at the current animation `t`. Linearly interpolates between - /// previous and target positions. Nodes that newly appeared use target as - /// both endpoints (they pop in place). Map _currentPositions() { final t = _layoutAnim.value; if (t >= 1.0) return _targetPositions; @@ -197,9 +215,6 @@ class _TopologyCanvasState extends State return out; } - /// Edge waypoints at the current animation `t`. Lerps element-wise when the - /// previous and target waypoint lists have the same length; otherwise - /// snaps to target (the rare case of an edge going from N-layer to M-layer). Map> _currentWaypoints() { final t = _layoutAnim.value; if (t >= 1.0) return _targetWaypoints; @@ -235,31 +250,31 @@ class _TopologyCanvasState extends State Map positions, Map> waypoints, ) { + // Build a device→role lookup for node sizing + final roleOf = {}; + for (final node in widget.graph.nodes) { + roleOf[node.device.id] = node.device.role; + } + final out = <_EdgeGeometry>[]; for (final edge in widget.graph.edges) { final fromTopLeft = positions[edge.fromDeviceId]; final toTopLeft = positions[edge.toDeviceId]; if (fromTopLeft == null || toTopLeft == null) continue; - final fromCenter = Offset( - fromTopLeft.dx + _nodeSize.width / 2, - fromTopLeft.dy + _nodeSize.height / 2, - ); - final toCenter = Offset( - toTopLeft.dx + _nodeSize.width / 2, - toTopLeft.dy + _nodeSize.height / 2, - ); + final fromSize = _roleSizeFor(roleOf[edge.fromDeviceId]); + final toSize = _roleSizeFor(roleOf[edge.toDeviceId]); + + final fromCenter = fromTopLeft + Offset(fromSize.width / 2, fromSize.height / 2); + final toCenter = toTopLeft + Offset(toSize.width / 2, toSize.height / 2); - // For edges with waypoints (multi-layer), exit points face the first/last - // waypoint rather than the other endpoint, so the curve enters/exits the - // card cleanly. final wps = waypoints[edge.link.id] ?? const []; final firstAimAt = wps.isNotEmpty ? wps.first : toCenter; final lastAimAt = wps.isNotEmpty ? wps.last : fromCenter; - final fromSide = _sideFacing(fromCenter, firstAimAt); - final toSide = _sideFacing(toCenter, lastAimAt); - final fromExit = _sideMidpoint(fromTopLeft, fromSide); - final toExit = _sideMidpoint(toTopLeft, toSide); + final fromSide = _sideFacing(fromCenter, firstAimAt, fromSize); + final toSide = _sideFacing(toCenter, lastAimAt, toSize); + final fromExit = _sideMidpoint(fromTopLeft, fromSide, fromSize); + final toExit = _sideMidpoint(toTopLeft, toSide, toSize); out.add(_EdgeGeometry( edgeId: edge.link.id, @@ -272,54 +287,116 @@ class _TopologyCanvasState extends State fromSide: fromSide, toSide: toSide, waypoints: wps, + linkKind: edge.link.linkKind, )); } return out; } - _CardSide _sideFacing(Offset from, Offset to) { + _CardSide _sideFacing(Offset from, Offset to, Size nodeSize) { final dx = to.dx - from.dx; final dy = to.dy - from.dy; - final aspectRatio = _nodeSize.width / _nodeSize.height; + final aspectRatio = nodeSize.width / nodeSize.height; if (dx.abs() * aspectRatio > dy.abs()) { return dx >= 0 ? _CardSide.right : _CardSide.left; } return dy >= 0 ? _CardSide.bottom : _CardSide.top; } - Offset _sideMidpoint(Offset topLeft, _CardSide side) { + Offset _sideMidpoint(Offset topLeft, _CardSide side, Size nodeSize) { switch (side) { case _CardSide.left: - return Offset(topLeft.dx, topLeft.dy + _nodeSize.height / 2); + return Offset(topLeft.dx, topLeft.dy + nodeSize.height / 2); case _CardSide.right: - return Offset( - topLeft.dx + _nodeSize.width, topLeft.dy + _nodeSize.height / 2); + return Offset(topLeft.dx + nodeSize.width, topLeft.dy + nodeSize.height / 2); case _CardSide.top: - return Offset(topLeft.dx + _nodeSize.width / 2, topLeft.dy); + return Offset(topLeft.dx + nodeSize.width / 2, topLeft.dy); case _CardSide.bottom: - return Offset( - topLeft.dx + _nodeSize.width / 2, topLeft.dy + _nodeSize.height); + return Offset(topLeft.dx + nodeSize.width / 2, topLeft.dy + nodeSize.height); } } - /// Line-jump arcs at crossings between *straight* edges. Curved (waypoint- - /// bearing) edges don't participate — they already route around obstacles - /// by going through their virtual-node waypoints. + // ─── Universal jumper detection ────────────────────────────────────────── + // + // Samples both straight and curved edges into polyline segments, then tests + // all cross-edge segment pairs for intersections. The "under" edge (higher + // index j) receives the jump arc. + + /// Returns [n] evenly-spaced points along the edge path. + List _sampleEdgePath(_EdgeGeometry geo, int n) { + if (geo.waypoints.isEmpty) { + return [geo.fromExit, geo.toExit]; + } + final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; + if (pts.length == 2) return pts; + + final result = []; + // Replicate the quadratic Bézier sampling used in _drawCurvedEdge. + // Each consecutive triple (prev-anchor, waypoint, next-anchor) is one + // quadratic segment. We sample t uniformly across all segments. + final anchors = []; + anchors.add(pts[0]); + for (var i = 1; i < pts.length - 1; i++) { + anchors.add(Offset( + (pts[i].dx + pts[i + 1].dx) / 2, + (pts[i].dy + pts[i + 1].dy) / 2, + )); + } + anchors.add(pts.last); + + final segCount = anchors.length - 1; + for (var k = 0; k < n; k++) { + final globalT = k / (n - 1); + final segIndexF = globalT * segCount; + final segIndex = segIndexF.floor().clamp(0, segCount - 1); + final localT = segIndexF - segIndex; + + final a0 = anchors[segIndex]; + final cp = pts[segIndex + 1]; + final a1 = anchors[segIndex + 1]; + + // Quadratic Bézier evaluation: B(t) = (1-t)²·a0 + 2(1-t)t·cp + t²·a1 + final mt = 1.0 - localT; + result.add(Offset( + mt * mt * a0.dx + 2 * mt * localT * cp.dx + localT * localT * a1.dx, + mt * mt * a0.dy + 2 * mt * localT * cp.dy + localT * localT * a1.dy, + )); + } + return result; + } + void _computeLineJumps(List<_EdgeGeometry> geos) { for (var i = 0; i < geos.length; i++) { geos[i].jumpPoints.clear(); } for (var i = 0; i < geos.length; i++) { final a = geos[i]; - if (a.waypoints.isNotEmpty) continue; + final samplesA = _sampleEdgePath(a, _bezierSamples); for (var j = i + 1; j < geos.length; j++) { final b = geos[j]; - if (b.waypoints.isNotEmpty) continue; if (_sharesDevice(a, b)) continue; - final hit = _segmentIntersection(a.fromExit, a.toExit, b.fromExit, b.toExit); - if (hit != null) b.jumpPoints.add(hit); + final samplesB = _sampleEdgePath(b, _bezierSamples); + // Stop at the first hit per edge pair — adjacent sample segments near + // a crossing can each detect a slightly offset intersection, producing + // duplicate jump points. Multiple hits cause the path to draw backwards + // between them, placing arcs far from the actual crossing. + bool found = false; + for (var ai = 0; ai < samplesA.length - 1 && !found; ai++) { + for (var bi = 0; bi < samplesB.length - 1; bi++) { + final hit = _segmentIntersection( + samplesA[ai], samplesA[ai + 1], + samplesB[bi], samplesB[bi + 1], + ); + if (hit != null) { + b.jumpPoints.add(hit); + found = true; + break; + } + } + } } } + // Sort jump points along each edge from fromExit outward for (final geo in geos) { if (geo.jumpPoints.isEmpty) continue; geo.jumpPoints.sort((p1, p2) { @@ -356,8 +433,6 @@ class _TopologyCanvasState extends State String? bestId; double bestDist = _hitTolerance; for (final geo in geos) { - // For curved edges, sample the Bézier path and use the nearest sample. - // For straight edges, distance to segment. final d = geo.waypoints.isEmpty ? _distanceToSegment(localPos, geo.fromExit, geo.toExit) : _distanceToCurve(localPos, geo); @@ -371,8 +446,7 @@ class _TopologyCanvasState extends State double _distanceToSegment(Offset p, Offset a, Offset b) { final ax = a.dx, ay = a.dy, bx = b.dx, by = b.dy; - final dx = bx - ax; - final dy = by - ay; + final dx = bx - ax, dy = by - ay; final lenSq = dx * dx + dy * dy; if (lenSq < 1e-6) return (p - a).distance; var t = ((p.dx - ax) * dx + (p.dy - ay) * dy) / lenSq; @@ -380,9 +454,6 @@ class _TopologyCanvasState extends State return (p - Offset(ax + t * dx, ay + t * dy)).distance; } - /// Approximate distance to a Bézier curve by sampling segments between the - /// path control points (fromExit, waypoints..., toExit) and taking the min - /// segment distance. Coarse but cheap. double _distanceToCurve(Offset p, _EdgeGeometry geo) { final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; var best = double.infinity; @@ -430,6 +501,7 @@ class _TopologyCanvasState extends State minScale: 0.2, maxScale: 2.5, boundaryMargin: const EdgeInsets.all(200), + transformationController: widget.transformationController, child: AnimatedBuilder( animation: _layoutAnim, builder: (context, _) { @@ -451,17 +523,28 @@ class _TopologyCanvasState extends State onExit: (_) => _hoveredEdgeId.value = null, child: Stack( children: [ - // Painter — repaints on hover via inner AnimatedBuilder. + // Dot-grid background — static, never repaints + Positioned.fill( + child: RepaintBoundary( + child: CustomPaint( + painter: _GridPainter( + dotColor: cs.outlineVariant.withValues(alpha: 0.25), + ), + ), + ), + ), + // Edges + flow dots — repaints on hover or flow phase Positioned.fill( child: RepaintBoundary( child: AnimatedBuilder( - animation: _hoveredEdgeId, + animation: Listenable.merge([_hoveredEdgeId, _flowAnim]), builder: (_, _) { return CustomPaint( painter: _CanvasPainter( edgeGeos: edgeGeos, jumpRadius: _jumpRadius, hoveredEdgeId: _hoveredEdgeId.value, + flowPhase: _flowAnim.value, theme: _CanvasTheme.from(Theme.of(context)), ), ); @@ -469,7 +552,7 @@ class _TopologyCanvasState extends State ), ), ), - // Devices on top, hover-aware. + // Device nodes on top for (final node in widget.graph.nodes) if (positions[node.device.id] != null) Positioned( @@ -482,6 +565,7 @@ class _TopologyCanvasState extends State hoveredDeviceId: _hoveredDeviceId, connectedEdgeIds: _deviceToEdges[node.device.id] ?? const [], + nodeSize: _roleSizeFor(node.device.role), onTap: widget.onDeviceTap, ), ), @@ -509,6 +593,7 @@ class _EdgeGeometry { required this.fromSide, required this.toSide, required this.waypoints, + required this.linkKind, }); final String edgeId; @@ -520,14 +605,9 @@ class _EdgeGeometry { final Offset toExit; final _CardSide fromSide; final _CardSide toSide; - - /// Intermediate points the edge passes through. Empty for single-layer - /// edges (drawn as straight lines). Non-empty for multi-layer edges (drawn - /// as sequential quadratic Béziers through these points). final List waypoints; + final NetworkLinkKind? linkKind; - /// Line-jump points along the *main* segment. Only populated for straight - /// edges (curved/waypoint edges skip line jumps). final List jumpPoints = []; } @@ -535,57 +615,122 @@ class _EdgeGeometry { class _CanvasTheme { const _CanvasTheme({ - required this.defaultEdgeColor, - required this.hoverEdgeColor, required this.chipBg, required this.chipHoverBg, required this.chipBorder, required this.chipHoverBorder, required this.chipText, required this.chipHoverText, + required this.copperColor, + required this.fiberColor, + required this.wirelessColor, + required this.virtualColor, + required this.unknownColor, + required this.hoverEdgeColor, }); factory _CanvasTheme.from(ThemeData theme) { final cs = theme.colorScheme; return _CanvasTheme( - defaultEdgeColor: cs.outline.withValues(alpha: 0.75), - hoverEdgeColor: cs.primary, chipBg: cs.surfaceContainerHigh.withValues(alpha: 0.95), chipHoverBg: cs.primaryContainer, chipBorder: cs.outlineVariant.withValues(alpha: 0.7), chipHoverBorder: cs.primary, chipText: cs.onSurfaceVariant, chipHoverText: cs.onPrimaryContainer, + copperColor: cs.primary, + fiberColor: cs.tertiary, + wirelessColor: cs.secondary, + virtualColor: cs.outline, + unknownColor: cs.outline, + hoverEdgeColor: cs.primary, ); } - final Color defaultEdgeColor; - final Color hoverEdgeColor; final Color chipBg; final Color chipHoverBg; final Color chipBorder; final Color chipHoverBorder; final Color chipText; final Color chipHoverText; + final Color copperColor; + final Color fiberColor; + final Color wirelessColor; + final Color virtualColor; + final Color unknownColor; + final Color hoverEdgeColor; + + Color edgeColorForKind(NetworkLinkKind? kind) { + switch (kind) { + case NetworkLinkKind.copper: + return copperColor; + case NetworkLinkKind.fiber: + return fiberColor; + case NetworkLinkKind.wireless: + return wirelessColor; + case NetworkLinkKind.virtual: + return virtualColor; + case NetworkLinkKind.unknown: + case null: + return unknownColor; + } + } } -// ─── Painter ─────────────────────────────────────────────────────────────── +// ─── Dot-grid background painter ───────────────────────────────────────────── + +class _GridPainter extends CustomPainter { + const _GridPainter({required this.dotColor}); + + final Color dotColor; + static const double _spacing = 24; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = dotColor + ..style = PaintingStyle.fill; + var x = _spacing; + while (x < size.width) { + var y = _spacing; + while (y < size.height) { + canvas.drawCircle(Offset(x, y), 1.0, paint); + y += _spacing; + } + x += _spacing; + } + } + + @override + bool shouldRepaint(covariant _GridPainter old) => old.dotColor != dotColor; +} + +// ─── Main canvas painter ────────────────────────────────────────────────────── class _CanvasPainter extends CustomPainter { _CanvasPainter({ required this.edgeGeos, required this.jumpRadius, required this.hoveredEdgeId, + required this.flowPhase, required this.theme, }); final List<_EdgeGeometry> edgeGeos; final double jumpRadius; final String? hoveredEdgeId; + final double flowPhase; final _CanvasTheme theme; + // Pre-compute chip positions keyed by (deviceId, side) to separate overlaps. + final Map> _chipSlots = {}; + @override void paint(Canvas canvas, Size size) { + _chipSlots.clear(); + _assignChipSlots(); + + // Draw non-hovered edges first (behind hovered) for (final geo in edgeGeos) { if (geo.edgeId == hoveredEdgeId) continue; _drawEdge(canvas, geo, hovered: false); @@ -594,19 +739,87 @@ class _CanvasPainter extends CustomPainter { if (geo.edgeId != hoveredEdgeId) continue; _drawEdge(canvas, geo, hovered: true); } + // Chips on top for (final geo in edgeGeos) { - if (geo.edgeId == hoveredEdgeId) continue; - _drawChips(canvas, geo, hovered: false); - } - for (final geo in edgeGeos) { - if (geo.edgeId != hoveredEdgeId) continue; - _drawChips(canvas, geo, hovered: true); + _drawChips(canvas, geo, hovered: geo.edgeId == hoveredEdgeId); } } + // ─── Chip slot pre-computation ────────────────────────────────────────── + // + // Group chips by (deviceId, cardSide) and assign staggered offsets so they + // don't overlap when multiple edges leave the same side of a device. + + void _assignChipSlots() { + // Collect all chips + final grouped = >{}; + for (final geo in edgeGeos) { + _collectChip(grouped, geo.fromDeviceId, geo.fromSide, geo.fromExit, + geo.fromPortLabel, geo.edgeId, isFrom: true); + _collectChip(grouped, geo.toDeviceId, geo.toSide, geo.toExit, + geo.toPortLabel, geo.edgeId, isFrom: false); + } + // Sort within each group by the axis perpendicular to the side, then + // assign evenly-spaced offsets. + for (final entry in grouped.entries) { + final slots = entry.value; + if (slots.length <= 1) { + _chipSlots[entry.key] = slots; + continue; + } + // Sort by anchor position perpendicular to side direction + final side = slots.first.side; + final isHorizontalSide = + side == _CardSide.top || side == _CardSide.bottom; + slots.sort((a, b) => isHorizontalSide + ? a.anchor.dx.compareTo(b.anchor.dx) + : a.anchor.dy.compareTo(b.anchor.dy)); + // Measure chip widths and space them out with 4dp gap + const chipH = 16.0; + const gap = 4.0; + var cursor = 0.0; + for (final slot in slots) { + slot.offset = cursor; + cursor += chipH + gap; // approximate; actual chip width varies + } + // Center the group around the original anchor + final total = cursor - gap; + for (final slot in slots) { + slot.offset -= total / 2; + } + _chipSlots[entry.key] = slots; + } + } + + void _collectChip( + Map> grouped, + String deviceId, + _CardSide side, + Offset anchor, + String label, + String edgeId, { + required bool isFrom, + }) { + if (label.isEmpty) return; + final key = '$deviceId:${side.name}'; + grouped.putIfAbsent(key, () => []).add(_ChipSlot( + deviceId: deviceId, + edgeId: edgeId, + side: side, + anchor: anchor, + label: label, + isFrom: isFrom, + )); + } + + // ─── Edge drawing ──────────────────────────────────────────────────────── + void _drawEdge(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) { - final color = hovered ? theme.hoverEdgeColor : theme.defaultEdgeColor; - final strokeWidth = hovered ? 2.5 : 1.4; + final baseColor = theme.edgeColorForKind(geo.linkKind); + final color = hovered + ? theme.hoverEdgeColor + : baseColor.withValues(alpha: 0.75); + final strokeWidth = hovered ? 3.0 : 1.8; final paint = Paint() ..color = color ..strokeWidth = strokeWidth @@ -619,11 +832,14 @@ class _CanvasPainter extends CustomPainter { _drawStraightEdge(canvas, geo, paint); } - // Endpoint plug dots. + // Endpoint plug dots final dotPaint = Paint()..color = color..style = PaintingStyle.fill; final radius = hovered ? 4.0 : 3.0; canvas.drawCircle(geo.fromExit, radius, dotPaint); canvas.drawCircle(geo.toExit, radius, dotPaint); + + // Data-flow dots + _drawFlowDots(canvas, geo, color); } void _drawStraightEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) { @@ -651,26 +867,16 @@ class _CanvasPainter extends CustomPainter { canvas.drawPath(path, paint); } - /// Render an edge through its waypoints as sequential quadratic Bézier - /// curves. The control point for each segment is the waypoint itself, - /// with each segment's anchor being the midpoint between consecutive - /// waypoints. This produces a smooth C1-continuous curve passing AT each - /// waypoint, mimicking what the eye expects from "a cable bending through - /// multiple connection points." void _drawCurvedEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) { final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; final path = Path()..moveTo(pts.first.dx, pts.first.dy); if (pts.length == 2) { - path.lineTo(pts.last.dx, pts.last.dy); - canvas.drawPath(path, paint); + // Treat as straight for jump purposes + _drawStraightEdgeFromPts(canvas, pts[0], pts[1], geo.jumpPoints, paint); return; } - // For each intermediate waypoint, draw a quadratic Bézier from the - // previous-anchor to the next-anchor, with this waypoint as the control. - // First anchor: midpoint between pts[0] and pts[1]. Last anchor: midpoint - // between pts[n-2] and pts[n-1]. This produces a smooth curve passing - // through every waypoint. + var anchor = Offset( (pts[0].dx + pts[1].dx) / 2, (pts[0].dy + pts[1].dy) / 2, @@ -681,28 +887,194 @@ class _CanvasPainter extends CustomPainter { (pts[i].dx + pts[i + 1].dx) / 2, (pts[i].dy + pts[i + 1].dy) / 2, ); - path.quadraticBezierTo(pts[i].dx, pts[i].dy, nextAnchor.dx, nextAnchor.dy); + path.quadraticBezierTo( + pts[i].dx, pts[i].dy, nextAnchor.dx, nextAnchor.dy); anchor = nextAnchor; } path.lineTo(pts.last.dx, pts.last.dy); + + if (geo.jumpPoints.isEmpty) { + canvas.drawPath(path, paint); + } else { + // saveLayer lets BlendMode.clear punch transparent gaps in the curve so + // the arc appears as a visible bridge over the crossing. + canvas.saveLayer(null, Paint()); + canvas.drawPath(path, paint); + final erasePaint = Paint() + ..blendMode = BlendMode.clear + ..style = PaintingStyle.fill; + for (final jump in geo.jumpPoints) { + canvas.drawCircle(jump, jumpRadius + 2, erasePaint); + } + _drawCurvedJumpArcs(canvas, geo, paint); + canvas.restore(); + } + } + + void _drawStraightEdgeFromPts( + Canvas canvas, Offset from, Offset to, List jumps, Paint paint) { + final dx = to.dx - from.dx; + final dy = to.dy - from.dy; + final len = math.sqrt(dx * dx + dy * dy); + if (len < 1) return; + final ux = dx / len, uy = dy / len; + final path = Path()..moveTo(from.dx, from.dy); + for (final jump in jumps) { + final bx = jump.dx - ux * jumpRadius; + final by = jump.dy - uy * jumpRadius; + final ax = jump.dx + ux * jumpRadius; + final ay = jump.dy + uy * jumpRadius; + path.lineTo(bx, by); + path.arcToPoint(Offset(ax, ay), + radius: Radius.circular(jumpRadius), clockwise: false); + } + path.lineTo(to.dx, to.dy); canvas.drawPath(path, paint); } + /// Draw small arcs at jump points that lie near the curved path. + void _drawCurvedJumpArcs( + Canvas canvas, _EdgeGeometry geo, Paint paint) { + final samples = _sampleCurve(geo, 40); + for (final jump in geo.jumpPoints) { + // Find the tangent at the nearest sample point + Offset? tangent; + double bestDist = double.infinity; + for (var i = 0; i < samples.length - 1; i++) { + final mid = Offset( + (samples[i].dx + samples[i + 1].dx) / 2, + (samples[i].dy + samples[i + 1].dy) / 2, + ); + final d = (mid - jump).distance; + if (d < bestDist) { + bestDist = d; + final seg = samples[i + 1] - samples[i]; + final segLen = seg.distance; + tangent = segLen > 0.01 ? seg / segLen : null; + } + } + if (tangent == null) continue; + final tx = tangent.dx, ty = tangent.dy; + final before = jump - Offset(tx * jumpRadius, ty * jumpRadius); + final after = jump + Offset(tx * jumpRadius, ty * jumpRadius); + final arcPath = Path() + ..moveTo(before.dx, before.dy) + ..arcToPoint( + Offset(after.dx, after.dy), + radius: Radius.circular(jumpRadius), + clockwise: false, + ); + // White gap first + canvas.drawPath(arcPath, + paint..style = PaintingStyle.stroke..color = paint.color); + } + } + + List _sampleCurve(_EdgeGeometry geo, int n) { + final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; + if (pts.length == 2) return pts; + final anchors = []; + anchors.add(pts[0]); + for (var i = 1; i < pts.length - 1; i++) { + anchors.add(Offset( + (pts[i].dx + pts[i + 1].dx) / 2, + (pts[i].dy + pts[i + 1].dy) / 2, + )); + } + anchors.add(pts.last); + final segCount = anchors.length - 1; + final result = []; + for (var k = 0; k < n; k++) { + final gT = k / (n - 1); + final segF = gT * segCount; + final seg = segF.floor().clamp(0, segCount - 1); + final lt = segF - seg; + final a0 = anchors[seg]; + final cp = pts[seg + 1]; + final a1 = anchors[seg + 1]; + final mt = 1.0 - lt; + result.add(Offset( + mt * mt * a0.dx + 2 * mt * lt * cp.dx + lt * lt * a1.dx, + mt * mt * a0.dy + 2 * mt * lt * cp.dy + lt * lt * a1.dy, + )); + } + return result; + } + + // ─── Data-flow dot animation ───────────────────────────────────────────── + + void _drawFlowDots(Canvas canvas, _EdgeGeometry geo, Color edgeColor) { + final dotColor = edgeColor.withValues(alpha: 0.85); + const dotCount = 3; + const dotRadius = 2.5; + + final samples = geo.waypoints.isEmpty + ? [geo.fromExit, geo.toExit] + : _sampleCurve(geo, 40); + + for (var i = 0; i < dotCount; i++) { + final t = (flowPhase + i / dotCount) % 1.0; + final pos = _evalPathAt(samples, t); + canvas.drawCircle( + pos, + dotRadius, + Paint() + ..color = dotColor + ..style = PaintingStyle.fill, + ); + } + } + + /// Evaluate a position at parameter [t] (0–1) along a polyline of [samples]. + Offset _evalPathAt(List samples, double t) { + if (samples.length == 1) return samples[0]; + if (t <= 0) return samples.first; + if (t >= 1) return samples.last; + final segCount = samples.length - 1; + final f = t * segCount; + final idx = f.floor().clamp(0, segCount - 1); + final local = f - idx; + return Offset.lerp(samples[idx], samples[idx + 1], local)!; + } + + // ─── Port chips with separation ────────────────────────────────────────── + void _drawChips(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) { - _drawChip( - canvas, - anchor: geo.fromExit, - side: geo.fromSide, - label: geo.fromPortLabel, - hovered: hovered, - ); - _drawChip( - canvas, - anchor: geo.toExit, - side: geo.toSide, - label: geo.toPortLabel, - hovered: hovered, + _drawChipAtSlot(canvas, geo.edgeId, geo.fromDeviceId, geo.fromSide, + geo.fromPortLabel, hovered: hovered); + _drawChipAtSlot(canvas, geo.edgeId, geo.toDeviceId, geo.toSide, + geo.toPortLabel, hovered: hovered); + } + + void _drawChipAtSlot( + Canvas canvas, + String edgeId, + String deviceId, + _CardSide side, + String label, { + required bool hovered, + }) { + if (label.isEmpty) return; + final key = '$deviceId:${side.name}'; + final slots = _chipSlots[key]; + if (slots == null) return; + final slot = slots.firstWhere( + (s) => s.edgeId == edgeId, + orElse: () => _ChipSlot( + deviceId: deviceId, + edgeId: edgeId, + side: side, + anchor: Offset.zero, + label: label, + isFrom: true, + ), ); + _drawChip(canvas, + anchor: slot.anchor, + side: side, + label: label, + perpOffset: slot.offset, + hovered: hovered); } void _drawChip( @@ -710,6 +1082,7 @@ class _CanvasPainter extends CustomPainter { required Offset anchor, required _CardSide side, required String label, + required double perpOffset, required bool hovered, }) { if (label.isEmpty) return; @@ -732,38 +1105,48 @@ class _CanvasPainter extends CustomPainter { const padV = 2.0; final chipW = tp.width + padH * 2; final chipH = tp.height + padV * 2; - const gap = 6.0; + Offset chipTopLeft; switch (side) { case _CardSide.left: - chipTopLeft = Offset(anchor.dx - gap - chipW, anchor.dy - chipH / 2); - break; + chipTopLeft = Offset( + anchor.dx - gap - chipW, + anchor.dy - chipH / 2 + perpOffset, + ); case _CardSide.right: - chipTopLeft = Offset(anchor.dx + gap, anchor.dy - chipH / 2); - break; + chipTopLeft = Offset( + anchor.dx + gap, + anchor.dy - chipH / 2 + perpOffset, + ); case _CardSide.top: - chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy - gap - chipH); - break; + chipTopLeft = Offset( + anchor.dx - chipW / 2 + perpOffset, + anchor.dy - gap - chipH, + ); case _CardSide.bottom: - chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy + gap); - break; + chipTopLeft = Offset( + anchor.dx - chipW / 2 + perpOffset, + anchor.dy + gap, + ); } final rect = Rect.fromLTWH(chipTopLeft.dx, chipTopLeft.dy, chipW, chipH); final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(6)); - final bgPaint = Paint() - ..color = hovered ? theme.chipHoverBg : theme.chipBg - ..style = PaintingStyle.fill; - canvas.drawRRect(rrect, bgPaint); - - final borderPaint = Paint() - ..color = hovered ? theme.chipHoverBorder : theme.chipBorder - ..style = PaintingStyle.stroke - ..strokeWidth = hovered ? 1.2 : 0.7; - canvas.drawRRect(rrect, borderPaint); - + canvas.drawRRect( + rrect, + Paint() + ..color = hovered ? theme.chipHoverBg : theme.chipBg + ..style = PaintingStyle.fill, + ); + canvas.drawRRect( + rrect, + Paint() + ..color = hovered ? theme.chipHoverBorder : theme.chipBorder + ..style = PaintingStyle.stroke + ..strokeWidth = hovered ? 1.2 : 0.7, + ); tp.paint(canvas, Offset(chipTopLeft.dx + padH, chipTopLeft.dy + padV)); } @@ -771,11 +1154,33 @@ class _CanvasPainter extends CustomPainter { bool shouldRepaint(covariant _CanvasPainter old) { return old.edgeGeos != edgeGeos || old.hoveredEdgeId != hoveredEdgeId || - old.theme.defaultEdgeColor != theme.defaultEdgeColor; + old.flowPhase != flowPhase || + old.theme.copperColor != theme.copperColor; } } -// ─── Hover-aware device ─────────────────────────────────────────────────── +// ─── Chip slot helper ───────────────────────────────────────────────────────── + +class _ChipSlot { + _ChipSlot({ + required this.deviceId, + required this.edgeId, + required this.side, + required this.anchor, + required this.label, + required this.isFrom, + }); + + final String deviceId; + final String edgeId; + final _CardSide side; + final Offset anchor; + final String label; + final bool isFrom; + double offset = 0; +} + +// ─── Hover-aware device ─────────────────────────────────────────────────────── class _HoverAwareDevice extends StatelessWidget { const _HoverAwareDevice({ @@ -784,6 +1189,7 @@ class _HoverAwareDevice extends StatelessWidget { required this.hoveredEdgeId, required this.hoveredDeviceId, required this.connectedEdgeIds, + required this.nodeSize, required this.onTap, }); @@ -792,6 +1198,7 @@ class _HoverAwareDevice extends StatelessWidget { final ValueNotifier hoveredEdgeId; final ValueNotifier hoveredDeviceId; final List connectedEdgeIds; + final Size nodeSize; final void Function(NetworkDevice device)? onTap; @override @@ -814,6 +1221,7 @@ class _HoverAwareDevice extends StatelessWidget { portCount: node.ports.length, isSelected: selectedId == node.device.id, isHighlighted: isOwnHover || isEdgeEndpoint, + nodeSize: nodeSize, onTap: () => onTap?.call(node.device), ); }, diff --git a/lib/screens/network_map/widgets/topology_legend.dart b/lib/screens/network_map/widgets/topology_legend.dart new file mode 100644 index 00000000..e2c93ff8 --- /dev/null +++ b/lib/screens/network_map/widgets/topology_legend.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; + +import '../../../models/network/network_device.dart'; +import '../../../models/network/network_link.dart'; + +/// Collapsible legend overlay explaining device role border colours and link +/// type colours. Positioned at the bottom-left of the topology canvas view. +class TopologyLegend extends StatefulWidget { + const TopologyLegend({super.key}); + + @override + State createState() => _TopologyLegendState(); +} + +class _TopologyLegendState extends State + with SingleTickerProviderStateMixin { + bool _expanded = false; + late final AnimationController _anim; + late final Animation _fade; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _fade = CurvedAnimation(parent: _anim, curve: Curves.easeOut); + } + + @override + void dispose() { + _anim.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded) { + _anim.forward(); + } else { + _anim.reverse(); + } + } + + Color _roleColor(ColorScheme cs, NetworkDeviceRole role) { + switch (role) { + case NetworkDeviceRole.core: + return cs.error; + case NetworkDeviceRole.distribution: + return cs.tertiary; + case NetworkDeviceRole.access: + return cs.primary; + case NetworkDeviceRole.edge: + return cs.secondary; + case NetworkDeviceRole.endpoint: + return cs.outline; + } + } + + Color _linkColor(ColorScheme cs, NetworkLinkKind kind) { + switch (kind) { + case NetworkLinkKind.copper: + return cs.primary; + case NetworkLinkKind.fiber: + return cs.tertiary; + case NetworkLinkKind.wireless: + return cs.secondary; + case NetworkLinkKind.virtual: + return cs.outline; + case NetworkLinkKind.unknown: + return cs.outline; + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Expand/collapse toggle chip + Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + elevation: 2, + child: InkWell( + onTap: _toggle, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.legend_toggle_outlined, + size: 16, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + 'Legend', + style: tt.labelSmall?.copyWith( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + AnimatedRotation( + turns: _expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.expand_more, + size: 16, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + // Expandable panel + FadeTransition( + opacity: _fade, + child: SizeTransition( + sizeFactor: _fade, + axisAlignment: -1, + child: Padding( + padding: const EdgeInsets.only(top: 6), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + elevation: 3, + child: Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + width: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // ── Device Roles ───────────────────────────── + Text( + 'Device Roles', + style: tt.labelSmall?.copyWith( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 6), + for (final role in NetworkDeviceRole.values) + _LegendRow( + color: _roleColor(cs, role), + label: role.label, + isLine: false, + ), + const SizedBox(height: 10), + Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.5)), + const SizedBox(height: 10), + // ── Link Types ─────────────────────────────── + Text( + 'Link Types', + style: tt.labelSmall?.copyWith( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 6), + for (final kind in NetworkLinkKind.values) + _LegendRow( + color: _linkColor(cs, kind), + label: kind.label, + isLine: true, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _LegendRow extends StatelessWidget { + const _LegendRow({ + required this.color, + required this.label, + required this.isLine, + }); + + final Color color; + final String label; + final bool isLine; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + if (isLine) + // Coloured line segment for link types + Container( + width: 20, + height: 3, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ) + else + // Coloured square for device roles (border indicator) + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + border: Border.all(color: color, width: 2), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 8), + Text( + label, + style: tt.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/network_map/widgets/topology_minimap.dart b/lib/screens/network_map/widgets/topology_minimap.dart new file mode 100644 index 00000000..77443939 --- /dev/null +++ b/lib/screens/network_map/widgets/topology_minimap.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; + +import '../../../models/network/network_device.dart'; +import '../../../models/network/topology_graph.dart'; + +/// Scaled-down overview of the topology canvas with a viewport indicator. +/// +/// Shows all device nodes as small colour-coded rectangles and all edges as +/// thin lines. A semi-transparent white rectangle indicates the current +/// viewport within the full canvas. Tapping on the minimap pans the main +/// canvas to centre on that point. +class TopologyMinimap extends StatefulWidget { + const TopologyMinimap({ + super.key, + required this.graph, + required this.nodePositions, + required this.canvasSize, + required this.controller, + required this.viewportSize, + }); + + final TopologyGraph graph; + + /// Current interpolated node positions (top-left corners) from the canvas. + final Map nodePositions; + + final Size canvasSize; + final TransformationController controller; + final Size viewportSize; + + static const Size _minimapSize = Size(160, 100); + + @override + State createState() => _TopologyMinimapState(); +} + +class _TopologyMinimapState extends State { + @override + void initState() { + super.initState(); + widget.controller.addListener(_onTransformChanged); + } + + @override + void didUpdateWidget(covariant TopologyMinimap oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onTransformChanged); + widget.controller.addListener(_onTransformChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onTransformChanged); + super.dispose(); + } + + void _onTransformChanged() => setState(() {}); + + double get _scaleX { + if (widget.canvasSize.width == 0) return 1; + return TopologyMinimap._minimapSize.width / widget.canvasSize.width; + } + + double get _scaleY { + if (widget.canvasSize.height == 0) return 1; + return TopologyMinimap._minimapSize.height / widget.canvasSize.height; + } + + double get _scale => _scaleX < _scaleY ? _scaleX : _scaleY; + + Offset _toMinimap(Offset canvasPoint) { + final s = _scale; + final offsetX = (TopologyMinimap._minimapSize.width - widget.canvasSize.width * s) / 2; + final offsetY = (TopologyMinimap._minimapSize.height - widget.canvasSize.height * s) / 2; + return Offset(canvasPoint.dx * s + offsetX, canvasPoint.dy * s + offsetY); + } + + /// Convert a tap on the minimap to a canvas point, then pan the main canvas + /// so that point appears at the viewport centre. + void _onTap(Offset localPos) { + final s = _scale; + if (s == 0) return; + final offsetX = (TopologyMinimap._minimapSize.width - widget.canvasSize.width * s) / 2; + final offsetY = (TopologyMinimap._minimapSize.height - widget.canvasSize.height * s) / 2; + final canvasX = (localPos.dx - offsetX) / s; + final canvasY = (localPos.dy - offsetY) / s; + + final currentScale = widget.controller.value.getMaxScaleOnAxis(); + final tx = widget.viewportSize.width / 2 - canvasX * currentScale; + final ty = widget.viewportSize.height / 2 - canvasY * currentScale; + final result = Matrix4.translationValues(tx, ty, 0); + result.multiply(Matrix4.diagonal3Values(currentScale, currentScale, 1.0)); + widget.controller.value = result; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return GestureDetector( + onTapDown: (d) => _onTap(d.localPosition), + child: Material( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + elevation: 2, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: TopologyMinimap._minimapSize.width, + height: TopologyMinimap._minimapSize.height, + child: CustomPaint( + painter: _MinimapPainter( + graph: widget.graph, + nodePositions: widget.nodePositions, + canvasSize: widget.canvasSize, + controller: widget.controller, + viewportSize: widget.viewportSize, + colorScheme: cs, + scale: _scale, + toMinimap: _toMinimap, + ), + ), + ), + ), + ), + ); + } +} + +class _MinimapPainter extends CustomPainter { + _MinimapPainter({ + required this.graph, + required this.nodePositions, + required this.canvasSize, + required this.controller, + required this.viewportSize, + required this.colorScheme, + required this.scale, + required this.toMinimap, + }); + + final TopologyGraph graph; + final Map nodePositions; + final Size canvasSize; + final TransformationController controller; + final Size viewportSize; + final ColorScheme colorScheme; + final double scale; + final Offset Function(Offset) toMinimap; + + static const Size _nodeRectSize = Size(8, 5); + + Color _roleColor(NetworkDeviceRole? role) { + switch (role) { + case NetworkDeviceRole.core: + return colorScheme.error; + case NetworkDeviceRole.distribution: + return colorScheme.tertiary; + case NetworkDeviceRole.access: + return colorScheme.primary; + case NetworkDeviceRole.edge: + return colorScheme.secondary; + case NetworkDeviceRole.endpoint: + case null: + return colorScheme.outline; + } + } + + @override + void paint(Canvas canvas, Size size) { + // Edges + final edgePaint = Paint() + ..color = colorScheme.outline.withValues(alpha: 0.4) + ..strokeWidth = 0.6 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + for (final edge in graph.edges) { + final fromPos = nodePositions[edge.fromDeviceId]; + final toPos = nodePositions[edge.toDeviceId]; + if (fromPos == null || toPos == null) continue; + + // Use node centres + final from = toMinimap(fromPos + const Offset(80, 55)); + final to = toMinimap(toPos + const Offset(80, 55)); + canvas.drawLine(from, to, edgePaint); + } + + // Device nodes + for (final node in graph.nodes) { + final pos = nodePositions[node.device.id]; + if (pos == null) continue; + final minimapPos = toMinimap(pos); + final rect = Rect.fromLTWH( + minimapPos.dx - _nodeRectSize.width / 2, + minimapPos.dy - _nodeRectSize.height / 2, + _nodeRectSize.width, + _nodeRectSize.height, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(1.5)), + Paint() + ..color = _roleColor(node.device.role) + ..style = PaintingStyle.fill, + ); + } + + // Viewport rectangle + final m = controller.value; + final currentScale = m.getMaxScaleOnAxis(); + if (currentScale > 0) { + // The translation is in the 3rd column of the matrix + final tx = m.getTranslation().x; + final ty = m.getTranslation().y; + + // Viewport in canvas coordinates + final vpLeft = -tx / currentScale; + final vpTop = -ty / currentScale; + final vpWidth = viewportSize.width / currentScale; + final vpHeight = viewportSize.height / currentScale; + + final tl = toMinimap(Offset(vpLeft, vpTop)); + final br = toMinimap(Offset(vpLeft + vpWidth, vpTop + vpHeight)); + final vpRect = Rect.fromPoints(tl, br); + + // Fill + canvas.drawRect( + vpRect, + Paint() + ..color = colorScheme.primary.withValues(alpha: 0.08) + ..style = PaintingStyle.fill, + ); + // Border + canvas.drawRect( + vpRect, + Paint() + ..color = colorScheme.primary.withValues(alpha: 0.6) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke, + ); + } + } + + @override + bool shouldRepaint(covariant _MinimapPainter old) => true; +} diff --git a/lib/screens/network_map/widgets/zoom_controls.dart b/lib/screens/network_map/widgets/zoom_controls.dart new file mode 100644 index 00000000..ff283498 --- /dev/null +++ b/lib/screens/network_map/widgets/zoom_controls.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +/// Vertical +/−/fit-to-view zoom controls for the topology canvas. +/// Requires a [TransformationController] shared with the [InteractiveViewer]. +class ZoomControls extends StatelessWidget { + const ZoomControls({ + super.key, + required this.controller, + required this.canvasSize, + required this.viewportSize, + }); + + final TransformationController controller; + + /// Full canvas size (from [computeSugiyamaLayout] result). + final Size canvasSize; + + /// Viewport widget size (the screen area the canvas is displayed in). + final Size viewportSize; + + static const double _zoomIn = 1.25; + static const double _zoomOut = 0.8; + static const double _minScale = 0.2; + static const double _maxScale = 2.5; + + void _applyZoom(double factor) { + final current = controller.value.getMaxScaleOnAxis(); + final next = (current * factor).clamp(_minScale, _maxScale); + final scale = next / current; + final cx = viewportSize.width / 2; + final cy = viewportSize.height / 2; + // Compose: T(cx,cy) * Scale(s) * T(-cx,-cy) * currentTransform + final result = Matrix4.translationValues(cx, cy, 0); + result.multiply(Matrix4.diagonal3Values(scale, scale, 1.0)); + result.multiply(Matrix4.translationValues(-cx, -cy, 0)); + result.multiply(controller.value); + controller.value = result; + } + + void _fitToView() { + if (canvasSize.isEmpty) return; + final scaleX = viewportSize.width / canvasSize.width; + final scaleY = viewportSize.height / canvasSize.height; + final scale = (scaleX < scaleY ? scaleX : scaleY).clamp(_minScale, _maxScale); + final tx = (viewportSize.width - canvasSize.width * scale) / 2; + final ty = (viewportSize.height - canvasSize.height * scale) / 2; + // Build: T(tx,ty,0) * Scale(s,s,1) — maps canvas (0,0) → (tx,ty) on screen + final result = Matrix4.translationValues(tx, ty, 0); + result.multiply(Matrix4.diagonal3Values(scale, scale, 1.0)); + controller.value = result; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + elevation: 2, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ZoomButton( + icon: Icons.add, + tooltip: 'Zoom in', + onTap: () => _applyZoom(_zoomIn), + ), + _Divider(color: cs.outlineVariant.withValues(alpha: 0.4)), + _ZoomButton( + icon: Icons.remove, + tooltip: 'Zoom out', + onTap: () => _applyZoom(_zoomOut), + ), + _Divider(color: cs.outlineVariant.withValues(alpha: 0.4)), + _ZoomButton( + icon: Icons.fit_screen_outlined, + tooltip: 'Fit to view', + onTap: _fitToView, + ), + ], + ), + ), + ); + } +} + +class _ZoomButton extends StatelessWidget { + const _ZoomButton({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 40, + height: 40, + child: Icon(icon, size: 18, color: cs.onSurfaceVariant), + ), + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider({required this.color}); + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Divider(height: 1, color: color), + ); + } +} diff --git a/supabase/migrations/20260605110000_network_device_status.sql b/supabase/migrations/20260605110000_network_device_status.sql new file mode 100644 index 00000000..3fb61af7 --- /dev/null +++ b/supabase/migrations/20260605110000_network_device_status.sql @@ -0,0 +1,4 @@ +-- Add status column to network_devices for online/offline/warning indicators. +ALTER TABLE network_devices + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'unknown' + CHECK (status IN ('online', 'offline', 'warning', 'unknown'));