Initial Commit : Network Map

This commit is contained in:
2026-05-21 06:29:14 +08:00
parent 82141dd61c
commit 5d0a66bb52
32 changed files with 6782 additions and 1 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,465 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/network_device.dart';
import '../../models/network/network_location.dart';
import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
import 'widgets/port_edit_dialog.dart';
import 'widgets/port_list_tile.dart';
/// Add or edit a device. If [deviceId] is null, creates a new device.
class NetworkMapDeviceEditScreen extends ConsumerStatefulWidget {
const NetworkMapDeviceEditScreen({
super.key,
this.deviceId,
this.initialSiteId,
});
final String? deviceId;
final String? initialSiteId;
@override
ConsumerState<NetworkMapDeviceEditScreen> createState() =>
_NetworkMapDeviceEditScreenState();
}
class _NetworkMapDeviceEditScreenState
extends ConsumerState<NetworkMapDeviceEditScreen> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _vendorCtrl = TextEditingController();
final _modelCtrl = TextEditingController();
final _serialCtrl = TextEditingController();
final _mgmtIpCtrl = TextEditingController();
final _macCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
NetworkDeviceKind _kind = NetworkDeviceKind.switchDevice;
NetworkDeviceRole? _role;
String? _siteId;
String? _locationId;
bool _saving = false;
bool _initialized = false;
@override
void dispose() {
_nameCtrl.dispose();
_vendorCtrl.dispose();
_modelCtrl.dispose();
_serialCtrl.dispose();
_mgmtIpCtrl.dispose();
_macCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
void _prefillFromDevice(NetworkDevice d, List<NetworkLocation> locations) {
if (_initialized) return;
_nameCtrl.text = d.name;
_vendorCtrl.text = d.vendor ?? '';
_modelCtrl.text = d.model ?? '';
_serialCtrl.text = d.serial ?? '';
_mgmtIpCtrl.text = d.mgmtIp ?? '';
_macCtrl.text = d.mac ?? '';
_notesCtrl.text = d.notes ?? '';
_kind = d.kind;
_role = d.role;
// Resolve the device's location → its site for the pickers.
if (d.locationId != null) {
_locationId = d.locationId;
for (final l in locations) {
if (l.id == d.locationId) {
_siteId = l.siteId;
break;
}
}
}
_initialized = true;
}
/// Resolve a location_id to save. Preference order:
/// 1. Explicitly picked location.
/// 2. Site picked but no specific location → use/create the site's default.
/// 3. Nothing picked → null (unassigned).
Future<String?> _resolveLocationIdToSave() async {
if (_locationId != null) return _locationId;
if (_siteId == null) return null;
final loc = await ref
.read(networkSitesControllerProvider)
.ensureDefaultLocation(_siteId!);
return loc.id;
}
Future<void> _showAddLocationDialog() async {
if (_siteId == null) return;
final nameCtrl = TextEditingController();
NetworkLocationKind kind = NetworkLocationKind.room;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: const Text('New location'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Name (e.g. Floor 3, Rack U-12)',
),
),
const SizedBox(height: 12),
DropdownButtonFormField<NetworkLocationKind>(
initialValue: kind,
items: NetworkLocationKind.values
.map((k) => DropdownMenuItem(
value: k,
child: Text(_locationKindLabel(k)),
))
.toList(),
onChanged: (v) => setLocal(
() => kind = v ?? NetworkLocationKind.room,
),
decoration: const InputDecoration(labelText: 'Kind'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Create'),
),
],
),
),
);
if (ok == true && nameCtrl.text.trim().isNotEmpty) {
final created = await ref.read(networkSitesControllerProvider).createLocation(
siteId: _siteId!,
name: nameCtrl.text.trim(),
kind: kind,
);
if (mounted) setState(() => _locationId = created.id);
}
}
String _locationKindLabel(NetworkLocationKind k) {
switch (k) {
case NetworkLocationKind.building:
return 'Building';
case NetworkLocationKind.floor:
return 'Floor';
case NetworkLocationKind.room:
return 'Room';
case NetworkLocationKind.rack:
return 'Rack';
case NetworkLocationKind.wallJack:
return 'Wall jack';
case NetworkLocationKind.other:
return 'Other';
}
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
try {
final controller = ref.read(networkDevicesControllerProvider);
final locationId = await _resolveLocationIdToSave();
if (widget.deviceId == null) {
await controller.createDevice(
name: _nameCtrl.text.trim(),
kind: _kind,
role: _role,
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(),
mgmtIp: _mgmtIpCtrl.text.trim().isEmpty ? null : _mgmtIpCtrl.text.trim(),
mac: _macCtrl.text.trim().isEmpty ? null : _macCtrl.text.trim(),
locationId: locationId,
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
);
} else {
final existing =
await ref.read(networkDeviceByIdProvider(widget.deviceId!).future);
if (existing == null) return;
final updated = existing.copyWith(
name: _nameCtrl.text.trim(),
kind: _kind,
role: _role,
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(),
mgmtIp: _mgmtIpCtrl.text.trim().isEmpty ? null : _mgmtIpCtrl.text.trim(),
mac: _macCtrl.text.trim().isEmpty ? null : _macCtrl.text.trim(),
locationId: locationId,
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
);
await controller.updateDevice(updated);
}
if (mounted) context.pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _addPort(String deviceId) async {
await showPortEditDialog(
context: context,
ref: ref,
deviceId: deviceId,
);
}
@override
Widget build(BuildContext context) {
final existingAsync = widget.deviceId == null
? const AsyncValue<NetworkDevice?>.data(null)
: ref.watch(networkDeviceByIdProvider(widget.deviceId!));
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;
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
title: Text(widget.deviceId == null ? 'New device' : 'Edit device'),
actions: [
TextButton(
onPressed: _saving ? null : _save,
child: _saving ? const Text('Saving…') : const Text('Save'),
),
],
),
body: existingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(error: e),
data: (device) {
if (device != null) {
_prefillFromDevice(device, locationsAsync.valueOrNull ?? const []);
}
final locationsForSite = (locationsAsync.valueOrNull ?? const [])
.where((l) => _siteId != null && l.siteId == _siteId)
.toList();
return ResponsiveBody(
maxWidth: 720,
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
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<NetworkDeviceKind>(
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<NetworkDeviceRole?>(
initialValue: _role,
items: [
const DropdownMenuItem<NetworkDeviceRole?>(
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<String?>(
initialValue: _siteId,
items: [
const DropdownMenuItem<String?>(
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<String?>(
initialValue: _locationId,
items: [
const DropdownMenuItem<String?>(
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.'
: 'Specific 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: 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'),
),
],
),
_PortsList(deviceId: widget.deviceId!),
],
],
),
),
),
);
},
),
);
}
}
class _PortsList extends ConsumerWidget {
const _PortsList({required this.deviceId});
final String deviceId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId));
return portsAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Text('Error: $e'),
data: (ports) {
if (ports.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('No ports yet.'),
);
}
return Column(
children: [
for (final p in ports)
PortListTile(
port: p,
canEdit: true,
onTap: () => showPortEditDialog(
context: context,
ref: ref,
deviceId: deviceId,
existing: p,
),
onDelete: () => ref
.read(networkDevicesControllerProvider)
.deletePort(p.id, deviceId: deviceId),
),
],
);
},
);
}
}
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/network_device.dart';
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/port_edit_dialog.dart';
import 'widgets/port_list_tile.dart';
class NetworkMapDeviceScreen extends ConsumerWidget {
const NetworkMapDeviceScreen({super.key, required this.deviceId});
final String deviceId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final deviceAsync = ref.watch(networkDeviceByIdProvider(deviceId));
final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId));
final linksAsync = ref.watch(networkLinksProvider);
final allPortsAsync = ref.watch(networkPortsProvider);
final allDevicesAsync = ref.watch(networkDevicesProvider);
final profileAsync = ref.watch(currentProfileProvider);
final canEdit = profileAsync.maybeWhen(
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
orElse: () => false,
);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
title: deviceAsync.when(
data: (d) => Text(d?.name ?? 'Device'),
loading: () => const Text('Device'),
error: (_, _) => const Text('Device'),
),
actions: [
if (canEdit)
IconButton(
tooltip: 'Edit device',
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.go('/network-map/device/$deviceId/edit'),
),
],
),
body: deviceAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(
error: e,
onRetry: () => ref.invalidate(networkDeviceByIdProvider(deviceId)),
),
data: (device) {
if (device == null) {
return const AppEmptyView(
icon: Icons.device_unknown_outlined,
title: 'Device not found',
);
}
final ports = portsAsync.valueOrNull ?? const [];
final links = linksAsync.valueOrNull ?? const [];
final allPorts = allPortsAsync.valueOrNull ?? const [];
final allDevices = allDevicesAsync.valueOrNull ?? const [];
final portToDevice = <String, String>{
for (final p in allPorts) p.id: p.deviceId,
};
final portLabel = <String, String>{
for (final p in allPorts) p.id: p.portNumber,
};
final deviceById = <String, NetworkDevice>{
for (final d in allDevices) d.id: d,
};
// For each port of this device, find the link and the other end.
String? otherDeviceName(String portId) {
for (final link in links) {
String? otherPort;
if (link.portA == portId) otherPort = link.portB;
if (link.portB == portId) otherPort = link.portA;
if (otherPort == null) continue;
final otherDevId = portToDevice[otherPort];
if (otherDevId == null) return null;
return deviceById[otherDevId]?.name;
}
return null;
}
String? otherPortLabel(String portId) {
for (final link in links) {
if (link.portA == portId) return portLabel[link.portB];
if (link.portB == portId) return portLabel[link.portA];
}
return null;
}
return ResponsiveBody(
maxWidth: 720,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
_MetaCard(device: device),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Ports (${ports.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (ports.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No ports configured yet.'),
)
else
for (final port in ports)
PortListTile(
port: port,
linkedDeviceName: otherDeviceName(port.id),
linkedPortLabel: otherPortLabel(port.id),
canEdit: canEdit,
onTap: canEdit
? () => showPortEditDialog(
context: context,
ref: ref,
deviceId: device.id,
existing: port,
)
: null,
),
if (device.notes != null && device.notes!.trim().isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(device.notes!),
),
],
],
),
);
},
),
);
}
}
class _MetaCard extends StatelessWidget {
const _MetaCard({required this.device});
final NetworkDevice device;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final rows = <(String, String)>[
('Type', device.kind.label),
if (device.role != null) ('Role', device.role!.label),
if (device.vendor != null) ('Vendor', device.vendor!),
if (device.model != null) ('Model', device.model!),
if (device.serial != null) ('Serial', device.serial!),
if (device.mgmtIp != null) ('Mgmt IP', device.mgmtIp!),
if (device.mac != null) ('MAC', device.mac!),
];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(device.name, style: tt.titleLarge),
const SizedBox(height: 8),
for (final (key, value) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(key,
style: tt.labelMedium?.copyWith(color: cs.onSurfaceVariant)),
),
Expanded(child: Text(value, style: tt.bodyMedium)),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/network_device.dart';
import '../../models/network/network_link.dart';
import '../../models/network/network_port.dart';
import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/network_map/network_import_provider.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../widgets/app_state_view.dart';
import 'widgets/import_diff_view.dart';
class NetworkMapImportReviewScreen extends ConsumerWidget {
const NetworkMapImportReviewScreen({super.key, required this.importId});
final String importId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final importAsync = ref.watch(networkImportByIdProvider(importId));
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/network-map'),
),
title: const Text('Review extraction'),
actions: [
TextButton.icon(
onPressed: () async {
await ref
.read(networkImportControllerProvider)
.discard(importId);
if (context.mounted) context.go('/network-map');
},
icon: const Icon(Icons.delete_outline),
label: const Text('Discard'),
),
],
),
body: importAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(error: e),
data: (imp) {
if (imp == null) {
return const AppEmptyView(
icon: Icons.upload_file_outlined,
title: 'Import not found',
);
}
final aiResult = imp.aiResult ?? const <String, dynamic>{};
return ImportDiffView(
aiResult: aiResult,
onApply: ({
required acceptedSites,
required acceptedDevices,
required acceptedPorts,
required acceptedLinks,
required acceptedVlans,
}) async {
await _applyExtraction(
context: context,
ref: ref,
importId: importId,
acceptedSites: acceptedSites,
acceptedDevices: acceptedDevices,
acceptedPorts: acceptedPorts,
acceptedLinks: acceptedLinks,
acceptedVlans: acceptedVlans,
);
},
);
},
),
);
}
Future<void> _applyExtraction({
required BuildContext context,
required WidgetRef ref,
required String importId,
required List<Map<String, dynamic>> acceptedSites,
required List<Map<String, dynamic>> acceptedDevices,
required List<Map<String, dynamic>> acceptedPorts,
required List<Map<String, dynamic>> acceptedLinks,
required List<Map<String, dynamic>> acceptedVlans,
}) async {
final devicesCtrl = ref.read(networkDevicesControllerProvider);
final sitesCtrl = ref.read(networkSitesControllerProvider);
// Create accepted sites first, keyed by AI-extracted site name. If the AI
// didn't extract any sites, we'll create a single "Imported" site so the
// devices have somewhere to live and become visible on the canvas.
final locationIdBySiteName = <String, String>{};
for (final raw in acceptedSites) {
final name = raw['name']?.toString();
if (name == null || name.isEmpty) continue;
try {
final site = await sitesCtrl.createSite(
name: name,
address: raw['address']?.toString(),
);
final loc = await sitesCtrl.ensureDefaultLocation(site.id);
locationIdBySiteName[name] = loc.id;
} catch (_) {
// Duplicate or RLS — skip; the unassigned bulk-move fallback will handle it.
}
}
// Fallback site: if no sites were extracted/accepted, create one so all
// imported devices land in one visible place (admins can rename/split later).
String? fallbackLocationId;
Future<String> getFallbackLocationId() async {
if (fallbackLocationId != null) return fallbackLocationId!;
final site = await sitesCtrl.createSite(name: 'Imported');
final loc = await sitesCtrl.ensureDefaultLocation(site.id);
fallbackLocationId = loc.id;
return fallbackLocationId!;
}
// Create devices, keyed by AI name → new device id.
final deviceIdByName = <String, String>{};
final createdDeviceIds = <String>[];
for (final raw in acceptedDevices) {
final name = raw['name']?.toString() ?? 'Unnamed';
final kind = NetworkDeviceKind.fromWire(raw['kind']?.toString());
// Resolve a location for this device: prefer the AI-extracted site, fall
// back to a synthesized "Imported" site so devices are never orphaned.
String? locationId;
final aiSiteName = raw['site_name']?.toString();
if (aiSiteName != null && locationIdBySiteName.containsKey(aiSiteName)) {
locationId = locationIdBySiteName[aiSiteName];
} else {
try {
locationId = await getFallbackLocationId();
} catch (_) {
// If site creation fails (RLS, dup), leave unassigned — user can
// bulk-move from the overview screen.
locationId = null;
}
}
final created = await devicesCtrl.createDevice(
name: name,
kind: kind,
vendor: raw['vendor']?.toString(),
model: raw['model']?.toString(),
mgmtIp: raw['mgmt_ip']?.toString(),
locationId: locationId,
importSource: NetworkImportSource.aiImport,
);
deviceIdByName[name] = created.id;
createdDeviceIds.add(created.id);
}
// Create ports for each accepted port (resolve device by name).
final portIdByDeviceAndNumber = <String, String>{};
for (final raw in acceptedPorts) {
final deviceName = raw['device_name']?.toString();
final portNumber = raw['port_number']?.toString();
if (deviceName == null || portNumber == null) continue;
final deviceId = deviceIdByName[deviceName];
if (deviceId == null) continue;
try {
final port = await devicesCtrl.createPort(
deviceId: deviceId,
portNumber: portNumber,
portKind: NetworkPortKind.fromWire(raw['port_kind']?.toString()),
accessVlan: raw['access_vlan'] is int
? raw['access_vlan'] as int
: int.tryParse(raw['access_vlan']?.toString() ?? ''),
isTrunk: raw['is_trunk'] == true,
);
portIdByDeviceAndNumber['$deviceId|$portNumber'] = port.id;
} catch (_) {
// Port might already exist due to UNIQUE constraint — skip silently.
}
}
// Create VLANs.
for (final raw in acceptedVlans) {
final vlanId = raw['vlan_id'];
final name = raw['name']?.toString();
if (vlanId is! int || name == null) continue;
try {
await devicesCtrl.createVlan(
vlanId: vlanId,
name: name,
description: raw['description']?.toString(),
);
} catch (_) {
// Duplicate VLAN id — skip.
}
}
// Create links: need to find/create both endpoint ports.
final createdLinkIds = <String>[];
for (final raw in acceptedLinks) {
final deviceA = raw['device_a']?.toString();
final deviceB = raw['device_b']?.toString();
final portALabel = raw['port_a']?.toString() ?? 'unknown';
final portBLabel = raw['port_b']?.toString() ?? 'unknown';
if (deviceA == null || deviceB == null) continue;
final devAId = deviceIdByName[deviceA];
final devBId = deviceIdByName[deviceB];
if (devAId == null || devBId == null) continue;
// Resolve or create endpoint ports.
final aKey = '$devAId|$portALabel';
final bKey = '$devBId|$portBLabel';
String? portAId = portIdByDeviceAndNumber[aKey];
String? portBId = portIdByDeviceAndNumber[bKey];
try {
if (portAId == null) {
final p = await devicesCtrl.createPort(
deviceId: devAId, portNumber: portALabel);
portAId = p.id;
portIdByDeviceAndNumber[aKey] = p.id;
}
if (portBId == null) {
final p = await devicesCtrl.createPort(
deviceId: devBId, portNumber: portBLabel);
portBId = p.id;
portIdByDeviceAndNumber[bKey] = p.id;
}
} catch (_) {
continue;
}
try {
final link = await devicesCtrl.createLink(
portA: portAId,
portB: portBId,
linkKind: NetworkLinkKind.fromWire(raw['link_kind']?.toString()),
);
createdLinkIds.add(link.id);
} catch (_) {
// Link already exists or violates CHECK — skip.
}
}
await ref.read(networkImportControllerProvider).markApplied(
importId: importId,
deviceIds: createdDeviceIds,
linkIds: createdLinkIds,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Applied ${createdDeviceIds.length} devices, '
'${createdLinkIds.length} links.',
),
));
context.go('/network-map');
}
}
}
@@ -0,0 +1,170 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/network_import.dart';
import '../../providers/network_map/network_import_provider.dart';
import '../../providers/profile_provider.dart';
import '../../widgets/responsive_body.dart';
class NetworkMapImportScreen extends ConsumerStatefulWidget {
const NetworkMapImportScreen({super.key});
@override
ConsumerState<NetworkMapImportScreen> createState() =>
_NetworkMapImportScreenState();
}
class _NetworkMapImportScreenState extends ConsumerState<NetworkMapImportScreen> {
bool _uploading = false;
String? _statusMessage;
Future<void> _pickAndStart() async {
final result = await FilePicker.platform.pickFiles(
withData: true,
type: FileType.custom,
allowedExtensions: const [
'pdf', 'png', 'jpg', 'jpeg', 'webp', 'vsdx', 'csv', 'xlsx', 'docx',
],
);
if (result == null || result.files.isEmpty) return;
final picked = result.files.first;
final bytes = picked.bytes;
if (bytes == null) {
setState(() => _statusMessage = 'Could not read file bytes.');
return;
}
setState(() {
_uploading = true;
_statusMessage = 'Uploading ${picked.name}';
});
final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null) {
setState(() {
_uploading = false;
_statusMessage = 'Not signed in.';
});
return;
}
try {
final controller = ref.read(networkImportControllerProvider);
final importId = await controller.uploadAndQueueExtraction(
bytes: bytes,
filename: picked.name,
createdBy: profile.id,
);
setState(() {
_statusMessage = 'Extracting topology — this can take up to a minute…';
});
final result = await controller.runExtraction(importId);
if (!mounted) return;
if (result.status == NetworkImportStatus.review) {
context.go('/network-map/import/$importId/review');
} else if (result.status == NetworkImportStatus.failed) {
setState(() {
_statusMessage =
result.errorMessage ?? 'Extraction failed. Starting from blank canvas.';
});
} else {
setState(() {
_statusMessage = 'Unexpected status: ${result.status.wire}';
});
}
} catch (e) {
if (mounted) {
setState(() => _statusMessage = 'Error: $e');
}
} finally {
if (mounted) setState(() => _uploading = false);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/network-map'),
),
title: const Text('Import network document'),
),
body: ResponsiveBody(
maxWidth: 720,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.auto_awesome_outlined, color: cs.primary),
const SizedBox(width: 8),
Text(
'AI-assisted extraction',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 8),
const Text(
'Upload an existing network document — Visio (.vsdx), PDF, '
'a photo of a whiteboard, or a spreadsheet — and we\'ll '
'try to extract devices, ports, links, and VLANs for you '
'to review. If extraction returns nothing useful, you '
'can still start with a blank canvas.',
),
const SizedBox(height: 16),
const Text(
'Supported: PDF, PNG/JPG/WebP, VSDX, CSV, XLSX, DOCX. '
'Max ~10 MB recommended.',
style: TextStyle(fontSize: 12),
),
],
),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _uploading ? null : _pickAndStart,
icon: _uploading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file_outlined),
label: Text(_uploading ? 'Working…' : 'Choose file'),
),
if (_statusMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Text(_statusMessage!),
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/network_device.dart';
import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../providers/profile_provider.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
import 'widgets/device_node.dart';
class NetworkMapOverviewScreen extends ConsumerWidget {
const NetworkMapOverviewScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sitesAsync = ref.watch(networkSitesProvider);
final devicesAsync = ref.watch(networkDevicesProvider);
final profileAsync = ref.watch(currentProfileProvider);
final canEdit = profileAsync.maybeWhen(
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
orElse: () => false,
);
return Scaffold(
floatingActionButton: canEdit
? FloatingActionButton.extended(
onPressed: () => _showAddSiteDialog(context, ref),
icon: const Icon(Icons.add_location_alt_outlined),
label: const Text('Add site'),
)
: null,
body: ResponsiveBody(
maxWidth: double.infinity,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
AppPageHeader(
title: 'Network Infrastructure',
subtitle: 'Drillable map of switches, routers, APs, and links',
actions: canEdit
? [
IconButton.filledTonal(
tooltip: 'Import document',
onPressed: () => context.go('/network-map/import'),
icon: const Icon(Icons.upload_file_outlined),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'VLANs',
onPressed: () => context.go('/network-map/vlans'),
icon: const Icon(Icons.lan_outlined),
),
]
: null,
),
_SummaryRow(
siteCount: sitesAsync.valueOrNull?.length ?? 0,
deviceCount: devicesAsync.valueOrNull?.length ?? 0,
),
const SizedBox(height: 16),
if (sitesAsync.isLoading)
const Padding(
padding: EdgeInsets.all(40),
child: Center(child: CircularProgressIndicator()),
)
else if (sitesAsync.hasError)
AppErrorView(
error: sitesAsync.error!,
onRetry: () => ref.invalidate(networkSitesProvider),
)
else if ((sitesAsync.valueOrNull ?? []).isEmpty)
AppEmptyView(
icon: Icons.hub_outlined,
title: 'No sites yet',
subtitle: canEdit
? 'Add your first site or import an existing network diagram to get started.'
: 'No network sites have been recorded yet.',
action: canEdit
? FilledButton.icon(
onPressed: () => _showAddSiteDialog(context, ref),
icon: const Icon(Icons.add_location_alt_outlined),
label: const Text('Add first site'),
)
: null,
)
else ...[
for (final site in sitesAsync.valueOrNull!)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _SiteCard(
name: site.name,
address: site.address,
deviceCount: (devicesAsync.valueOrNull ?? [])
.where((d) => d.locationId != null)
.length,
onTap: () => context.go('/network-map/site/${site.id}'),
),
),
],
_UnassignedSection(
devices: (devicesAsync.valueOrNull ?? [])
.where((d) => d.locationId == null)
.toList(),
canEdit: canEdit,
),
],
),
),
);
}
Future<void> _showAddSiteDialog(BuildContext context, WidgetRef ref) async {
final nameCtrl = TextEditingController();
final addressCtrl = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('New site'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
const SizedBox(height: 8),
TextField(
controller: addressCtrl,
decoration: const InputDecoration(labelText: 'Address (optional)'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Create'),
),
],
),
);
if (result == true && nameCtrl.text.trim().isNotEmpty) {
final profile = ref.read(currentProfileProvider).valueOrNull;
await ref.read(networkSitesControllerProvider).createSite(
name: nameCtrl.text.trim(),
address: addressCtrl.text.trim().isEmpty ? null : addressCtrl.text.trim(),
createdBy: profile?.id,
);
}
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({required this.siteCount, required this.deviceCount});
final int siteCount;
final int deviceCount;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: _SummaryCard(label: 'Sites', value: siteCount.toString(), icon: Icons.location_city_outlined)),
const SizedBox(width: 12),
Expanded(child: _SummaryCard(label: 'Devices', value: deviceCount.toString(), icon: Icons.devices_outlined)),
],
);
}
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({required this.label, required this.value, required this.icon});
final String label;
final String value;
final IconData icon;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(icon, color: cs.primary),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant)),
Text(value, style: tt.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
],
),
],
),
),
);
}
}
class _UnassignedSection extends ConsumerWidget {
const _UnassignedSection({required this.devices, required this.canEdit});
final List<NetworkDevice> devices;
final bool canEdit;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (devices.isEmpty) return const SizedBox.shrink();
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 24),
child: Card(
color: cs.tertiaryContainer.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.help_outline, color: cs.tertiary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Unassigned devices (${devices.length})',
style: tt.titleMedium,
),
),
if (canEdit)
TextButton.icon(
onPressed: () => _showBulkAssignDialog(context, ref, devices),
icon: const Icon(Icons.move_up),
label: const Text('Assign all to site…'),
),
],
),
const SizedBox(height: 8),
Text(
'These devices have no site/location yet, so they don\'t show on '
'any topology canvas. Tap one to assign it.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final d in devices)
DeviceNode(
device: d,
onTap: () => context.go('/network-map/device/${d.id}/edit'),
),
],
),
],
),
),
),
);
}
Future<void> _showBulkAssignDialog(
BuildContext context,
WidgetRef ref,
List<NetworkDevice> devices,
) async {
final sites = ref.read(networkSitesProvider).valueOrNull ?? const [];
if (sites.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Create a site first.')),
);
return;
}
String? picked = sites.first.id;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: Text('Assign ${devices.length} devices to a site'),
content: DropdownButtonFormField<String>(
initialValue: picked,
items: [
for (final s in sites)
DropdownMenuItem(value: s.id, child: Text(s.name)),
],
onChanged: (v) => setLocal(() => picked = v),
decoration: const InputDecoration(labelText: 'Target site'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Assign'),
),
],
),
),
);
if (ok != true || picked == null) return;
final sitesCtrl = ref.read(networkSitesControllerProvider);
final devicesCtrl = ref.read(networkDevicesControllerProvider);
final location = await sitesCtrl.ensureDefaultLocation(picked!);
for (final d in devices) {
final updated = d.copyWith(locationId: location.id);
await devicesCtrl.updateDevice(updated);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Moved ${devices.length} devices.')),
);
}
}
}
class _SiteCard extends StatelessWidget {
const _SiteCard({
required this.name,
required this.address,
required this.deviceCount,
required this.onTap,
});
final String name;
final String? address;
final int deviceCount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
CircleAvatar(
backgroundColor: cs.primaryContainer,
child: Icon(Icons.location_city, color: cs.onPrimaryContainer),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: tt.titleMedium),
if (address != null && address!.isNotEmpty)
Text(address!, style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
],
),
),
Text('$deviceCount devices', style: tt.labelMedium?.copyWith(color: cs.onSurfaceVariant)),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
],
),
),
),
);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/network/topology_graph.dart';
import '../../providers/network_map/network_sites_provider.dart';
import '../../providers/network_map/network_topology_provider.dart';
import '../../providers/profile_provider.dart';
import '../../widgets/app_state_view.dart';
import 'widgets/topology_canvas.dart';
class NetworkMapSiteScreen extends ConsumerWidget {
const NetworkMapSiteScreen({super.key, required this.siteId});
final String siteId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final topologyAsync = ref.watch(topologyForSiteProvider(siteId));
final viewMode = ref.watch(topologyViewModeProvider);
final sitesAsync = ref.watch(networkSitesProvider);
final profileAsync = ref.watch(currentProfileProvider);
final canEdit = profileAsync.maybeWhen(
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
orElse: () => false,
);
final siteName = sitesAsync.valueOrNull
?.firstWhere(
(s) => s.id == siteId,
orElse: () => sitesAsync.valueOrNull!.first,
)
.name;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/network-map'),
),
title: Text(siteName ?? 'Site'),
actions: [
SegmentedButton<TopologyViewMode>(
segments: const [
ButtonSegment(
value: TopologyViewMode.physical,
label: Text('Physical'),
icon: Icon(Icons.business_outlined),
),
ButtonSegment(
value: TopologyViewMode.logical,
label: Text('Logical'),
icon: Icon(Icons.account_tree_outlined),
),
],
selected: {viewMode},
onSelectionChanged: (s) {
ref.read(topologyViewModeProvider.notifier).state = s.first;
},
),
const SizedBox(width: 8),
if (canEdit)
IconButton(
tooltip: 'Add device',
icon: const Icon(Icons.add_circle_outline),
onPressed: () =>
context.go('/network-map/site/$siteId/device/new'),
),
],
),
body: topologyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(
error: e,
onRetry: () => ref.invalidate(topologyForSiteProvider(siteId)),
),
data: (graph) => TopologyCanvas(
graph: graph,
viewMode: viewMode,
onDeviceTap: (device) =>
context.go('/network-map/device/${device.id}'),
),
),
);
}
}
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/network_map/network_devices_provider.dart';
import '../../providers/profile_provider.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
class NetworkMapVlanScreen extends ConsumerWidget {
const NetworkMapVlanScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vlansAsync = ref.watch(networkVlansProvider);
final profileAsync = ref.watch(currentProfileProvider);
final canEdit = profileAsync.maybeWhen(
data: (p) => p?.role == 'admin' || p?.role == 'it_staff',
orElse: () => false,
);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/network-map'),
),
title: const Text('VLANs'),
),
floatingActionButton: canEdit
? FloatingActionButton.extended(
onPressed: () => _showAddDialog(context, ref),
icon: const Icon(Icons.add),
label: const Text('New VLAN'),
)
: null,
body: ResponsiveBody(
maxWidth: 720,
child: vlansAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => AppErrorView(error: e),
data: (vlans) {
if (vlans.isEmpty) {
return const AppEmptyView(
icon: Icons.lan_outlined,
title: 'No VLANs defined',
subtitle: 'Add a VLAN to start tagging access ports.',
);
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
const AppPageHeader(title: 'VLANs', subtitle: 'Network segmentation'),
for (final v in vlans)
Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: _parseColor(v.color) ??
Theme.of(context).colorScheme.primaryContainer,
child: Text(
v.vlanId.toString(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
title: Text(v.name),
subtitle: v.description == null ? null : Text(v.description!),
trailing: canEdit
? IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => ref
.read(networkDevicesControllerProvider)
.deleteVlan(v.id),
)
: null,
),
),
],
);
},
),
),
);
}
Color? _parseColor(String? hex) {
if (hex == null) return null;
var s = hex.replaceFirst('#', '');
if (s.length == 6) s = 'FF$s';
final n = int.tryParse(s, radix: 16);
if (n == null) return null;
return Color(n);
}
Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
final idCtrl = TextEditingController();
final nameCtrl = TextEditingController();
final descCtrl = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('New VLAN'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: idCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'VLAN ID (1-4094)'),
autofocus: true,
),
const SizedBox(height: 8),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 8),
TextField(
controller: descCtrl,
decoration:
const InputDecoration(labelText: 'Description (optional)'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Create'),
),
],
),
);
final vlanId = int.tryParse(idCtrl.text.trim());
final name = nameCtrl.text.trim();
if (ok == true && vlanId != null && vlanId >= 1 && vlanId <= 4094 && name.isNotEmpty) {
await ref.read(networkDevicesControllerProvider).createVlan(
vlanId: vlanId,
name: name,
description: descCtrl.text.trim().isEmpty ? null : descCtrl.text.trim(),
);
}
}
}
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import '../../../models/network/network_device.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].
class DeviceNode extends StatelessWidget {
const DeviceNode({
super.key,
required this.device,
this.portCount,
this.isSelected = false,
this.isHighlighted = false,
this.onTap,
});
final NetworkDevice device;
final int? portCount;
final bool isSelected;
final bool isHighlighted;
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:
return cs.error;
case NetworkDeviceRole.distribution:
return cs.tertiary;
case NetworkDeviceRole.access:
return cs.primary;
case NetworkDeviceRole.edge:
return cs.secondary;
case NetworkDeviceRole.endpoint:
case null:
return cs.outline;
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final accent = _accentForRole(cs, device.role);
final bgColor = isSelected
? cs.primaryContainer
: isHighlighted
? cs.secondaryContainer.withValues(alpha: 0.6)
: cs.surfaceContainerHigh;
final borderColor = isSelected
? cs.primary
: isHighlighted
? cs.primary.withValues(alpha: 0.8)
: accent.withValues(alpha: 0.6);
final borderWidth = isSelected ? 2.0 : (isHighlighted ? 1.5 : 1.0);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: borderColor, width: borderWidth),
boxShadow: (isHighlighted || isSelected)
? [
BoxShadow(
color: cs.primary.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 2),
),
]
: null,
),
child: Material(
color: Colors.transparent,
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(
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),
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<String>().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,
),
),
],
),
],
],
),
),
),
),
);
}
}
@@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
/// Side-by-side review of an AI extraction result.
///
/// Left column: items the AI proposed (devices, ports, links, vlans), each
/// togglable on/off with confidence tag. Right column: a summary panel that
/// shows what will be applied. Apply button persists the toggled-on items.
class ImportDiffView extends StatefulWidget {
const ImportDiffView({
super.key,
required this.aiResult,
required this.onApply,
});
final Map<String, dynamic> aiResult;
final Future<void> Function({
required List<Map<String, dynamic>> acceptedDevices,
required List<Map<String, dynamic>> acceptedPorts,
required List<Map<String, dynamic>> acceptedLinks,
required List<Map<String, dynamic>> acceptedVlans,
required List<Map<String, dynamic>> acceptedSites,
}) onApply;
@override
State<ImportDiffView> createState() => _ImportDiffViewState();
}
class _ImportDiffViewState extends State<ImportDiffView> {
late final Set<int> _enabledDevices;
late final Set<int> _enabledPorts;
late final Set<int> _enabledLinks;
late final Set<int> _enabledVlans;
late final Set<int> _enabledSites;
bool _applying = false;
List<Map<String, dynamic>> _asList(dynamic value) {
if (value is! List) return const [];
return value
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
@override
void initState() {
super.initState();
_enabledDevices = {
for (var i = 0; i < _asList(widget.aiResult['devices']).length; i++) i,
};
_enabledPorts = {
for (var i = 0; i < _asList(widget.aiResult['ports']).length; i++) i,
};
_enabledLinks = {
for (var i = 0; i < _asList(widget.aiResult['links']).length; i++) i,
};
_enabledVlans = {
for (var i = 0; i < _asList(widget.aiResult['vlans']).length; i++) i,
};
_enabledSites = {
for (var i = 0; i < _asList(widget.aiResult['sites']).length; i++) i,
};
}
Color _confidenceColor(BuildContext context, String? c) {
final cs = Theme.of(context).colorScheme;
switch (c) {
case 'high':
return cs.primary;
case 'medium':
return cs.tertiary;
case 'low':
return cs.error;
default:
return cs.outline;
}
}
Widget _itemTile({
required String primary,
String? secondary,
String? confidence,
required bool enabled,
required ValueChanged<bool?> onToggle,
}) {
return CheckboxListTile(
dense: true,
value: enabled,
onChanged: onToggle,
controlAffinity: ListTileControlAffinity.leading,
title: Text(primary),
subtitle: secondary != null ? Text(secondary) : null,
secondary: confidence == null
? null
: Chip(
label: Text(confidence),
backgroundColor:
_confidenceColor(context, confidence).withValues(alpha: 0.15),
labelStyle: TextStyle(color: _confidenceColor(context, confidence)),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 4),
),
);
}
Future<void> _onApplyPressed() async {
setState(() => _applying = true);
try {
final allSites = _asList(widget.aiResult['sites']);
final allDevices = _asList(widget.aiResult['devices']);
final allPorts = _asList(widget.aiResult['ports']);
final allLinks = _asList(widget.aiResult['links']);
final allVlans = _asList(widget.aiResult['vlans']);
await widget.onApply(
acceptedSites: [
for (var i = 0; i < allSites.length; i++)
if (_enabledSites.contains(i)) allSites[i],
],
acceptedDevices: [
for (var i = 0; i < allDevices.length; i++)
if (_enabledDevices.contains(i)) allDevices[i],
],
acceptedPorts: [
for (var i = 0; i < allPorts.length; i++)
if (_enabledPorts.contains(i)) allPorts[i],
],
acceptedLinks: [
for (var i = 0; i < allLinks.length; i++)
if (_enabledLinks.contains(i)) allLinks[i],
],
acceptedVlans: [
for (var i = 0; i < allVlans.length; i++)
if (_enabledVlans.contains(i)) allVlans[i],
],
);
} finally {
if (mounted) setState(() => _applying = false);
}
}
@override
Widget build(BuildContext context) {
final sites = _asList(widget.aiResult['sites']);
final devices = _asList(widget.aiResult['devices']);
final ports = _asList(widget.aiResult['ports']);
final links = _asList(widget.aiResult['links']);
final vlans = _asList(widget.aiResult['vlans']);
final notes = widget.aiResult['notes']?.toString() ?? '';
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
if (notes.isNotEmpty)
Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(notes)),
],
),
),
),
if (sites.isNotEmpty) ...[
_sectionHeader('Sites (${sites.length})'),
for (var i = 0; i < sites.length; i++)
_itemTile(
primary: sites[i]['name']?.toString() ?? '(unnamed)',
secondary: sites[i]['address']?.toString(),
enabled: _enabledSites.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledSites.add(i);
} else {
_enabledSites.remove(i);
}
}),
),
],
if (devices.isNotEmpty) ...[
_sectionHeader('Devices (${devices.length})'),
for (var i = 0; i < devices.length; i++)
_itemTile(
primary: devices[i]['name']?.toString() ?? '(unnamed)',
secondary: [
devices[i]['kind']?.toString() ?? '',
if (devices[i]['vendor'] != null) devices[i]['vendor'],
if (devices[i]['model'] != null) devices[i]['model'],
].whereType<String>().where((s) => s.isNotEmpty).join(''),
confidence: devices[i]['confidence']?.toString(),
enabled: _enabledDevices.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledDevices.add(i);
} else {
_enabledDevices.remove(i);
}
}),
),
],
if (ports.isNotEmpty) ...[
_sectionHeader('Ports (${ports.length})'),
for (var i = 0; i < ports.length; i++)
_itemTile(
primary:
'${ports[i]['device_name'] ?? '?'} · ${ports[i]['port_number'] ?? '?'}',
secondary: ports[i]['port_kind']?.toString(),
enabled: _enabledPorts.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledPorts.add(i);
} else {
_enabledPorts.remove(i);
}
}),
),
],
if (links.isNotEmpty) ...[
_sectionHeader('Links (${links.length})'),
for (var i = 0; i < links.length; i++)
_itemTile(
primary:
'${links[i]['device_a'] ?? '?'}${links[i]['device_b'] ?? '?'}',
secondary: [
if (links[i]['port_a'] != null) 'A: ${links[i]['port_a']}',
if (links[i]['port_b'] != null) 'B: ${links[i]['port_b']}',
if (links[i]['link_kind'] != null) links[i]['link_kind'],
].whereType<String>().join(''),
confidence: links[i]['confidence']?.toString(),
enabled: _enabledLinks.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledLinks.add(i);
} else {
_enabledLinks.remove(i);
}
}),
),
],
if (vlans.isNotEmpty) ...[
_sectionHeader('VLANs (${vlans.length})'),
for (var i = 0; i < vlans.length; i++)
_itemTile(
primary:
'VLAN ${vlans[i]['vlan_id'] ?? '?'} · ${vlans[i]['name'] ?? ''}',
secondary: vlans[i]['description']?.toString(),
enabled: _enabledVlans.contains(i),
onToggle: (v) => setState(() {
if (v == true) {
_enabledVlans.add(i);
} else {
_enabledVlans.remove(i);
}
}),
),
],
if (sites.isEmpty &&
devices.isEmpty &&
ports.isEmpty &&
links.isEmpty &&
vlans.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Center(
child: Text(
'Extraction returned no structured items. You can still '
'discard this import and start from a blank canvas.',
),
),
),
],
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
Expanded(
child: Text(
'Selected: ${_enabledSites.length} sites, '
'${_enabledDevices.length} devices, '
'${_enabledLinks.length} links',
style: Theme.of(context).textTheme.labelSmall,
),
),
FilledButton.icon(
onPressed: _applying ? null : _onApplyPressed,
icon: _applying
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: Text(_applying ? 'Applying…' : 'Apply selected'),
),
],
),
),
),
],
);
}
Widget _sectionHeader(String label) {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/network/network_port.dart';
import '../../../providers/network_map/network_devices_provider.dart';
/// Opens an M3 dialog for creating or editing a network port.
///
/// When [existing] is null, the dialog operates in "add" mode and creates a new
/// port on [deviceId]. When [existing] is supplied, the dialog edits that port
/// in place. Returns true if the user saved a change.
Future<bool> showPortEditDialog({
required BuildContext context,
required WidgetRef ref,
required String deviceId,
NetworkPort? existing,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => _PortEditDialog(deviceId: deviceId, existing: existing),
);
return result == true;
}
class _PortEditDialog extends ConsumerStatefulWidget {
const _PortEditDialog({required this.deviceId, this.existing});
final String deviceId;
final NetworkPort? existing;
@override
ConsumerState<_PortEditDialog> createState() => _PortEditDialogState();
}
class _PortEditDialogState extends ConsumerState<_PortEditDialog> {
final _numberCtrl = TextEditingController();
final _speedCtrl = TextEditingController();
final _vlanCtrl = TextEditingController();
final _trunkVlansCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
NetworkPortKind? _kind;
bool _isTrunk = false;
bool _saving = false;
@override
void initState() {
super.initState();
final e = widget.existing;
if (e != null) {
_numberCtrl.text = e.portNumber;
_kind = e.portKind;
_speedCtrl.text = e.speedMbps?.toString() ?? '';
_vlanCtrl.text = e.accessVlan?.toString() ?? '';
_isTrunk = e.isTrunk;
_trunkVlansCtrl.text = e.trunkVlans.join(', ');
_notesCtrl.text = e.notes ?? '';
} else {
_kind = NetworkPortKind.rj45;
}
}
@override
void dispose() {
_numberCtrl.dispose();
_speedCtrl.dispose();
_vlanCtrl.dispose();
_trunkVlansCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
List<int> _parseTrunkVlans(String input) {
return input
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.map(int.tryParse)
.whereType<int>()
.toList();
}
Future<void> _save() async {
final number = _numberCtrl.text.trim();
if (number.isEmpty) return;
setState(() => _saving = true);
try {
final controller = ref.read(networkDevicesControllerProvider);
final speed = _speedCtrl.text.trim().isEmpty
? null
: int.tryParse(_speedCtrl.text.trim());
final vlan = _vlanCtrl.text.trim().isEmpty || _isTrunk
? null
: int.tryParse(_vlanCtrl.text.trim());
final trunkVlans = _isTrunk ? _parseTrunkVlans(_trunkVlansCtrl.text) : <int>[];
final notes = _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim();
if (widget.existing == null) {
await controller.createPort(
deviceId: widget.deviceId,
portNumber: number,
portKind: _kind,
speedMbps: speed,
accessVlan: vlan,
isTrunk: _isTrunk,
trunkVlans: trunkVlans,
notes: notes,
);
} else {
await controller.updatePort(
id: widget.existing!.id,
deviceId: widget.deviceId,
portNumber: number,
portKind: _kind,
speedMbps: speed,
accessVlan: vlan,
isTrunk: _isTrunk,
trunkVlans: trunkVlans,
notes: notes,
);
}
if (mounted) Navigator.of(context).pop(true);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
return AlertDialog(
title: Text(widget.existing == null ? 'Add port' : 'Edit port'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _numberCtrl,
autofocus: widget.existing == null,
decoration: const InputDecoration(
labelText: 'Port number *',
helperText: 'Exactly as labeled on the device (e.g. Gi0/1, eth0, 24)',
),
),
const SizedBox(height: 12),
DropdownButtonFormField<NetworkPortKind?>(
initialValue: _kind,
items: [
const DropdownMenuItem<NetworkPortKind?>(
value: null,
child: Text('(unknown)'),
),
for (final k in NetworkPortKind.values)
DropdownMenuItem(value: k, child: Text(k.label)),
],
onChanged: (v) => setState(() => _kind = v),
decoration: const InputDecoration(labelText: 'Kind'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _speedCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Speed (Mbps)',
helperText: 'e.g. 1000, 10000',
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _vlanCtrl,
enabled: !_isTrunk,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: 'Access VLAN',
helperText: _isTrunk
? 'Disabled — trunk port'
: 'Single VLAN ID (14094)',
),
),
),
],
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
value: _isTrunk,
onChanged: (v) => setState(() => _isTrunk = v),
contentPadding: EdgeInsets.zero,
title: const Text('Trunk port'),
subtitle: Text(
'Carries multiple VLANs',
style: tt.bodySmall,
),
),
if (_isTrunk) ...[
const SizedBox(height: 4),
TextField(
controller: _trunkVlansCtrl,
decoration: const InputDecoration(
labelText: 'Allowed VLANs (comma-separated)',
helperText: 'e.g. 10, 20, 100-105',
),
),
],
const SizedBox(height: 12),
TextField(
controller: _notesCtrl,
maxLines: 2,
decoration: const InputDecoration(labelText: 'Notes'),
),
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving…' : 'Save'),
),
],
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../../models/network/network_port.dart';
class PortListTile extends StatelessWidget {
const PortListTile({
super.key,
required this.port,
this.linkedDeviceName,
this.linkedPortLabel,
this.canEdit = false,
this.onTap,
this.onDelete,
});
final NetworkPort port;
final String? linkedDeviceName;
final String? linkedPortLabel;
final bool canEdit;
final VoidCallback? onTap;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final isLinked = linkedDeviceName != null;
final subtitleParts = <String>[
if (port.portKind != null) port.portKind!.label,
if (port.speedMbps != null) '${port.speedMbps} Mbps',
if (port.isTrunk) 'Trunk',
if (!port.isTrunk && port.accessVlan != null) 'VLAN ${port.accessVlan}',
];
return ListTile(
onTap: onTap,
leading: CircleAvatar(
backgroundColor: isLinked
? cs.primaryContainer
: cs.surfaceContainerHighest,
child: Icon(
isLinked ? Icons.cable : Icons.cable_outlined,
color: isLinked ? cs.onPrimaryContainer : cs.onSurfaceVariant,
size: 18,
),
),
title: Text(port.portNumber, style: tt.bodyMedium),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (subtitleParts.isNotEmpty)
Text(
subtitleParts.join(''),
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
),
if (isLinked)
Text(
'$linkedDeviceName${linkedPortLabel != null ? " · $linkedPortLabel" : ""}',
style: tt.bodySmall?.copyWith(color: cs.primary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
trailing: canEdit
? IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete port',
onPressed: onDelete,
)
: null,
);
}
}
@@ -0,0 +1,823 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../models/network/network_device.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.
class TopologyCanvas extends StatefulWidget {
const TopologyCanvas({
super.key,
required this.graph,
required this.viewMode,
this.selectedDeviceId,
this.onDeviceTap,
});
final TopologyGraph graph;
final TopologyViewMode viewMode;
final String? selectedDeviceId;
final void Function(NetworkDevice device)? onDeviceTap;
@override
State<TopologyCanvas> createState() => _TopologyCanvasState();
}
class _TopologyCanvasState extends State<TopologyCanvas>
with SingleTickerProviderStateMixin {
// ─── Hover state ─────────────────────────────────────────────────────────
final ValueNotifier<String?> _hoveredEdgeId = ValueNotifier<String?>(null);
final ValueNotifier<String?> _hoveredDeviceId = ValueNotifier<String?>(null);
// ─── Animation ───────────────────────────────────────────────────────────
late AnimationController _layoutAnim;
// ─── Layout snapshots (for interpolation) ────────────────────────────────
Map<String, Offset> _prevPositions = const {};
Map<String, Offset> _targetPositions = const {};
Map<String, List<Offset>> _prevWaypoints = const {};
Map<String, List<Offset>> _targetWaypoints = const {};
Size _canvasSize = const Size(600, 400);
Map<String, List<String>> _deviceToEdges = const {};
// ─── Constants ───────────────────────────────────────────────────────────
static const Size _nodeSize = Size(160, 110);
static const double _hitTolerance = 8;
static const double _jumpRadius = 6;
@override
void initState() {
super.initState();
_layoutAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_runLayout(animate: false);
}
@override
void didUpdateWidget(covariant TopologyCanvas oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.graph, widget.graph) ||
oldWidget.viewMode != widget.viewMode) {
_runLayout(animate: true);
}
}
@override
void dispose() {
_hoveredEdgeId.dispose();
_hoveredDeviceId.dispose();
_layoutAnim.dispose();
super.dispose();
}
// ─── Layout orchestration ────────────────────────────────────────────────
void _runLayout({required bool animate}) {
// Snapshot current interpolated positions BEFORE recomputing target.
final newPrev = animate
? Map<String, Offset>.from(_currentPositions())
: <String, Offset>{};
final newPrevWaypoints = animate
? Map<String, List<Offset>>.from(_currentWaypoints())
: <String, List<Offset>>{};
final input = _buildSugiyamaInput();
final result = computeSugiyamaLayout(input);
setState(() {
_prevPositions = newPrev.isEmpty ? result.nodePositions : newPrev;
_targetPositions = result.nodePositions;
_prevWaypoints =
newPrevWaypoints.isEmpty ? result.edgeWaypoints : newPrevWaypoints;
_targetWaypoints = result.edgeWaypoints;
_canvasSize = result.canvasSize;
_deviceToEdges = _computeDeviceEdgeMap();
});
if (animate) {
_layoutAnim.value = 0;
_layoutAnim.forward();
} else {
_layoutAnim.value = 1.0;
}
}
SugiyamaInput _buildSugiyamaInput() {
final nodeIds = widget.graph.nodes.map((n) => n.device.id).toList();
final edges = widget.graph.edges
.map((e) => SugiyamaEdge(
id: e.link.id,
from: e.fromDeviceId,
to: e.toDeviceId,
))
.toList();
Map<String, int>? preassigned;
if (widget.viewMode == TopologyViewMode.logical) {
preassigned = {};
for (final node in widget.graph.nodes) {
final role = node.device.role;
if (role != null) {
preassigned[node.device.id] = _roleToLayer(role);
}
}
}
// 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.
return SugiyamaInput(
nodeIds: nodeIds,
edges: edges,
preassignedLayers: preassigned,
// Sizes default for now; future: pass per-device width if cards become
// variable-width.
);
}
int _roleToLayer(NetworkDeviceRole role) {
switch (role) {
case NetworkDeviceRole.core:
return 0;
case NetworkDeviceRole.distribution:
return 1;
case NetworkDeviceRole.access:
return 2;
case NetworkDeviceRole.edge:
return 3;
case NetworkDeviceRole.endpoint:
return 4;
}
}
// ─── 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<String, Offset> _currentPositions() {
final t = _layoutAnim.value;
if (t >= 1.0) return _targetPositions;
if (t <= 0.0) return _prevPositions.isEmpty ? _targetPositions : _prevPositions;
final out = <String, Offset>{};
for (final entry in _targetPositions.entries) {
final target = entry.value;
final prev = _prevPositions[entry.key] ?? target;
out[entry.key] = Offset.lerp(prev, target, t)!;
}
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<String, List<Offset>> _currentWaypoints() {
final t = _layoutAnim.value;
if (t >= 1.0) return _targetWaypoints;
if (t <= 0.0) return _prevWaypoints.isEmpty ? _targetWaypoints : _prevWaypoints;
final out = <String, List<Offset>>{};
for (final entry in _targetWaypoints.entries) {
final target = entry.value;
final prev = _prevWaypoints[entry.key];
if (prev == null || prev.length != target.length) {
out[entry.key] = target;
continue;
}
out[entry.key] = [
for (var i = 0; i < target.length; i++)
Offset.lerp(prev[i], target[i], t)!,
];
}
return out;
}
Map<String, List<String>> _computeDeviceEdgeMap() {
final map = <String, List<String>>{};
for (final edge in widget.graph.edges) {
map.putIfAbsent(edge.fromDeviceId, () => []).add(edge.link.id);
map.putIfAbsent(edge.toDeviceId, () => []).add(edge.link.id);
}
return map;
}
// ─── Edge geometry ───────────────────────────────────────────────────────
List<_EdgeGeometry> _buildEdgeGeometries(
Map<String, Offset> positions,
Map<String, List<Offset>> waypoints,
) {
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,
);
// 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 <Offset>[];
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);
out.add(_EdgeGeometry(
edgeId: edge.link.id,
fromDeviceId: edge.fromDeviceId,
toDeviceId: edge.toDeviceId,
fromPortLabel: edge.fromPortLabel,
toPortLabel: edge.toPortLabel,
fromExit: fromExit,
toExit: toExit,
fromSide: fromSide,
toSide: toSide,
waypoints: wps,
));
}
return out;
}
_CardSide _sideFacing(Offset from, Offset to) {
final dx = to.dx - from.dx;
final dy = to.dy - from.dy;
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) {
switch (side) {
case _CardSide.left:
return Offset(topLeft.dx, topLeft.dy + _nodeSize.height / 2);
case _CardSide.right:
return Offset(
topLeft.dx + _nodeSize.width, topLeft.dy + _nodeSize.height / 2);
case _CardSide.top:
return Offset(topLeft.dx + _nodeSize.width / 2, topLeft.dy);
case _CardSide.bottom:
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.
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;
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);
}
}
for (final geo in geos) {
if (geo.jumpPoints.isEmpty) continue;
geo.jumpPoints.sort((p1, p2) {
final d1 = (p1 - geo.fromExit).distanceSquared;
final d2 = (p2 - geo.fromExit).distanceSquared;
return d1.compareTo(d2);
});
}
}
bool _sharesDevice(_EdgeGeometry a, _EdgeGeometry b) {
return a.fromDeviceId == b.fromDeviceId ||
a.fromDeviceId == b.toDeviceId ||
a.toDeviceId == b.fromDeviceId ||
a.toDeviceId == b.toDeviceId;
}
Offset? _segmentIntersection(Offset p1, Offset p2, Offset p3, Offset p4) {
final s1x = p2.dx - p1.dx;
final s1y = p2.dy - p1.dy;
final s2x = p4.dx - p3.dx;
final s2y = p4.dy - p3.dy;
final denom = -s2x * s1y + s1x * s2y;
if (denom.abs() < 1e-6) return null;
final s = (-s1y * (p1.dx - p3.dx) + s1x * (p1.dy - p3.dy)) / denom;
final t = (s2x * (p1.dy - p3.dy) - s2y * (p1.dx - p3.dx)) / denom;
if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
return Offset(p1.dx + t * s1x, p1.dy + t * s1y);
}
return null;
}
String? _hitTestEdge(Offset localPos, List<_EdgeGeometry> geos) {
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);
if (d < bestDist) {
bestDist = d;
bestId = geo.edgeId;
}
}
return bestId;
}
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 lenSq = dx * dx + dy * dy;
if (lenSq < 1e-6) return (p - a).distance;
var t = ((p.dx - ax) * dx + (p.dy - ay) * dy) / lenSq;
t = t.clamp(0.0, 1.0);
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 = <Offset>[geo.fromExit, ...geo.waypoints, geo.toExit];
var best = double.infinity;
for (var i = 0; i < pts.length - 1; i++) {
final d = _distanceToSegment(p, pts[i], pts[i + 1]);
if (d < best) best = d;
}
return best;
}
// ─── Build ───────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (widget.graph.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hub_outlined, size: 56, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'No devices in this view yet',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Import a document or add devices manually.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
);
}
return InteractiveViewer(
constrained: false,
minScale: 0.2,
maxScale: 2.5,
boundaryMargin: const EdgeInsets.all(200),
child: AnimatedBuilder(
animation: _layoutAnim,
builder: (context, _) {
final positions = _currentPositions();
final waypoints = _currentWaypoints();
final edgeGeos = _buildEdgeGeometries(positions, waypoints);
_computeLineJumps(edgeGeos);
return SizedBox(
width: _canvasSize.width,
height: _canvasSize.height,
child: MouseRegion(
onHover: (event) {
final hit = _hitTestEdge(event.localPosition, edgeGeos);
if (hit != _hoveredEdgeId.value) {
_hoveredEdgeId.value = hit;
}
},
onExit: (_) => _hoveredEdgeId.value = null,
child: Stack(
children: [
// Painter — repaints on hover via inner AnimatedBuilder.
Positioned.fill(
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _hoveredEdgeId,
builder: (_, _) {
return CustomPaint(
painter: _CanvasPainter(
edgeGeos: edgeGeos,
jumpRadius: _jumpRadius,
hoveredEdgeId: _hoveredEdgeId.value,
theme: _CanvasTheme.from(Theme.of(context)),
),
);
},
),
),
),
// Devices on top, hover-aware.
for (final node in widget.graph.nodes)
if (positions[node.device.id] != null)
Positioned(
left: positions[node.device.id]!.dx,
top: positions[node.device.id]!.dy,
child: _HoverAwareDevice(
node: node,
selectedId: widget.selectedDeviceId,
hoveredEdgeId: _hoveredEdgeId,
hoveredDeviceId: _hoveredDeviceId,
connectedEdgeIds:
_deviceToEdges[node.device.id] ?? const [],
onTap: widget.onDeviceTap,
),
),
],
),
),
);
},
),
);
}
}
enum _CardSide { left, right, top, bottom }
class _EdgeGeometry {
_EdgeGeometry({
required this.edgeId,
required this.fromDeviceId,
required this.toDeviceId,
required this.fromPortLabel,
required this.toPortLabel,
required this.fromExit,
required this.toExit,
required this.fromSide,
required this.toSide,
required this.waypoints,
});
final String edgeId;
final String fromDeviceId;
final String toDeviceId;
final String fromPortLabel;
final String toPortLabel;
final Offset fromExit;
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<Offset> waypoints;
/// Line-jump points along the *main* segment. Only populated for straight
/// edges (curved/waypoint edges skip line jumps).
final List<Offset> jumpPoints = [];
}
// ─── Theme snapshot ────────────────────────────────────────────────────────
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,
});
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,
);
}
final Color defaultEdgeColor;
final Color hoverEdgeColor;
final Color chipBg;
final Color chipHoverBg;
final Color chipBorder;
final Color chipHoverBorder;
final Color chipText;
final Color chipHoverText;
}
// ─── Painter ───────────────────────────────────────────────────────────────
class _CanvasPainter extends CustomPainter {
_CanvasPainter({
required this.edgeGeos,
required this.jumpRadius,
required this.hoveredEdgeId,
required this.theme,
});
final List<_EdgeGeometry> edgeGeos;
final double jumpRadius;
final String? hoveredEdgeId;
final _CanvasTheme theme;
@override
void paint(Canvas canvas, Size size) {
for (final geo in edgeGeos) {
if (geo.edgeId == hoveredEdgeId) continue;
_drawEdge(canvas, geo, hovered: false);
}
for (final geo in edgeGeos) {
if (geo.edgeId != hoveredEdgeId) continue;
_drawEdge(canvas, geo, hovered: true);
}
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);
}
}
void _drawEdge(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) {
final color = hovered ? theme.hoverEdgeColor : theme.defaultEdgeColor;
final strokeWidth = hovered ? 2.5 : 1.4;
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
if (geo.waypoints.isNotEmpty) {
_drawCurvedEdge(canvas, geo, paint);
} else {
_drawStraightEdge(canvas, geo, paint);
}
// 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);
}
void _drawStraightEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) {
final dx = geo.toExit.dx - geo.fromExit.dx;
final dy = geo.toExit.dy - geo.fromExit.dy;
final len = math.sqrt(dx * dx + dy * dy);
if (len < 1) return;
final ux = dx / len;
final uy = dy / len;
final path = Path()..moveTo(geo.fromExit.dx, geo.fromExit.dy);
for (final jump in geo.jumpPoints) {
final beforeX = jump.dx - ux * jumpRadius;
final beforeY = jump.dy - uy * jumpRadius;
final afterX = jump.dx + ux * jumpRadius;
final afterY = jump.dy + uy * jumpRadius;
path.lineTo(beforeX, beforeY);
path.arcToPoint(
Offset(afterX, afterY),
radius: Radius.circular(jumpRadius),
clockwise: false,
);
}
path.lineTo(geo.toExit.dx, geo.toExit.dy);
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 = <Offset>[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);
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,
);
path.lineTo(anchor.dx, anchor.dy);
for (var i = 1; i < pts.length - 1; i++) {
final nextAnchor = Offset(
(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);
anchor = nextAnchor;
}
path.lineTo(pts.last.dx, pts.last.dy);
canvas.drawPath(path, paint);
}
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,
);
}
void _drawChip(
Canvas canvas, {
required Offset anchor,
required _CardSide side,
required String label,
required bool hovered,
}) {
if (label.isEmpty) return;
final tp = TextPainter(
text: TextSpan(
text: label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: hovered ? theme.chipHoverText : theme.chipText,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout(maxWidth: 100);
const padH = 6.0;
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;
case _CardSide.right:
chipTopLeft = Offset(anchor.dx + gap, anchor.dy - chipH / 2);
break;
case _CardSide.top:
chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy - gap - chipH);
break;
case _CardSide.bottom:
chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy + gap);
break;
}
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);
tp.paint(canvas, Offset(chipTopLeft.dx + padH, chipTopLeft.dy + padV));
}
@override
bool shouldRepaint(covariant _CanvasPainter old) {
return old.edgeGeos != edgeGeos ||
old.hoveredEdgeId != hoveredEdgeId ||
old.theme.defaultEdgeColor != theme.defaultEdgeColor;
}
}
// ─── Hover-aware device ───────────────────────────────────────────────────
class _HoverAwareDevice extends StatelessWidget {
const _HoverAwareDevice({
required this.node,
required this.selectedId,
required this.hoveredEdgeId,
required this.hoveredDeviceId,
required this.connectedEdgeIds,
required this.onTap,
});
final TopologyNode node;
final String? selectedId;
final ValueNotifier<String?> hoveredEdgeId;
final ValueNotifier<String?> hoveredDeviceId;
final List<String> connectedEdgeIds;
final void Function(NetworkDevice device)? onTap;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => hoveredDeviceId.value = node.device.id,
onExit: (_) {
if (hoveredDeviceId.value == node.device.id) {
hoveredDeviceId.value = null;
}
},
child: AnimatedBuilder(
animation: Listenable.merge([hoveredEdgeId, hoveredDeviceId]),
builder: (_, _) {
final isOwnHover = hoveredDeviceId.value == node.device.id;
final isEdgeEndpoint = hoveredEdgeId.value != null &&
connectedEdgeIds.contains(hoveredEdgeId.value);
return DeviceNode(
device: node.device,
portCount: node.ports.length,
isSelected: selectedId == node.device.id,
isHighlighted: isOwnHover || isEdgeEndpoint,
onTap: () => onTap?.call(node.device),
);
},
),
);
}
}