From c5264186f33493140f72a9e16fe7fb7446a51397 Mon Sep 17 00:00:00 2001 From: TomH1004 Date: Wed, 15 Oct 2025 15:22:51 +0200 Subject: [PATCH] multicast/broadcast support --- Example/README.md | 36 +++ Example/unityudp_projects.json | 21 ++ README.md | 23 +- lib/models/project.dart | 34 ++- lib/models/project.g.dart | 9 +- lib/models/udp_package.dart | 33 ++- lib/models/udp_package.g.dart | 9 +- lib/screens/home_screen.dart | 11 +- lib/screens/project_screen.dart | 379 ++++++++++++++++++++++++++------ lib/services/udp_service.dart | 93 ++++++++ lib/widgets/package_dialog.dart | 175 +++++++++++++-- lib/widgets/project_dialog.dart | 5 +- 12 files changed, 712 insertions(+), 116 deletions(-) diff --git a/Example/README.md b/Example/README.md index 0820e6d..6f4751b 100644 --- a/Example/README.md +++ b/Example/README.md @@ -31,9 +31,19 @@ For the example commands to work, create a simple test scene: The example includes these simple commands: +### Standard Commands (Single IP) - **Toggle Cube**: Enables/Disables the TestCube GameObject - **Start Timer**: Starts a 10-second countdown timer - **Reset Scene**: Reloads the current scene +- **Rotate Cube**: Rotates the cube at different speeds +- **Change Color**: Changes the cube color (red, blue, green) + +### Broadcast Commands +- **Broadcast: Toggle All Cubes**: Sends the toggle command to all Unity instances on your network +- **Broadcast: Reset All Scenes**: Resets scenes on all Unity instances on your network + +### Multi-IP Commands +- **Multi-IP: Start Timers**: Sends the start timer command to multiple specific IP addresses simultaneously ## UDP Protocol @@ -55,9 +65,35 @@ You can add your own commands by: 1. Adding a new case in the `ProcessCommand` method in `UDPCommandListener.cs` 2. Creating a corresponding JSON entry in your project file +## Broadcast Mode + +Broadcast mode allows you to send UDP packets to all devices on your local network simultaneously. This is useful when you have multiple Unity instances running on different computers. + +### How to Use Broadcast +1. In the UnityUDP app, create or edit a package +2. Enable the "Broadcast Mode" toggle +3. Send the package - it will reach all Unity instances listening on the specified port + +### Network Requirements +- All devices must be on the same local network +- Firewall must allow UDP broadcast traffic +- Unity instances must be listening on the same port + +## Multiple IP Addresses + +You can send the same command to multiple specific IP addresses at once: + +1. In the UnityUDP app, create or edit a package +2. Add multiple IP addresses using the "Add IP" button +3. All added IPs will receive the packet when you send + +This is useful for targeting specific Unity instances without broadcasting to the entire network. + ## Troubleshooting - **Commands not working**: Check that your firewall allows UDP traffic on the specified port - **Can't connect**: Verify the IP address is correct (use `ipconfig` on Windows or `ifconfig` on Mac/Linux) - **Unity crashes**: Make sure all GameObject names match the ones in the script +- **Broadcast not working**: Ensure your firewall allows UDP broadcast (255.255.255.255) and all devices are on the same subnet +- **Multi-IP partial failure**: Check network connectivity to each IP address individually diff --git a/Example/unityudp_projects.json b/Example/unityudp_projects.json index 6b2c5fe..46f832d 100644 --- a/Example/unityudp_projects.json +++ b/Example/unityudp_projects.json @@ -75,6 +75,27 @@ "name": "Change Color Green", "data": "{\"command\":\"change_color\",\"value\":\"green\"}", "ipAddress": "127.0.0.1" + }, + { + "id": "cmd-013", + "name": "Broadcast: Toggle All Cubes", + "data": "{\"command\":\"toggle_cube\"}", + "ipAddresses": [], + "isBroadcast": true + }, + { + "id": "cmd-014", + "name": "Multi-IP: Start Timers", + "data": "{\"command\":\"start_timer\",\"value\":\"10\"}", + "ipAddresses": ["127.0.0.1", "192.168.1.100", "192.168.1.101"], + "isBroadcast": false + }, + { + "id": "cmd-015", + "name": "Broadcast: Reset All Scenes", + "data": "{\"command\":\"reset_scene\"}", + "ipAddresses": [], + "isBroadcast": true } ] } diff --git a/README.md b/README.md index e00a78e..e7d68f1 100644 --- a/README.md +++ b/README.md @@ -74,16 +74,27 @@ The example demonstrates simple but practical commands like enabling/disabling o 2. Tap the **"New Package"** button 3. Enter: - Package name (for identification) - - IP address (destination, e.g., 127.0.0.1 for localhost) + - **Broadcast Mode** (toggle on to broadcast to all devices on network) + - **IP addresses** (add one or more target IPs, not required for broadcast) - Data (the content to send) 4. Tap **"Save"** +#### Broadcast Mode +Enable broadcast mode to send UDP packets to all devices on your local network. This is useful for controlling multiple Unity instances simultaneously without specifying individual IP addresses. + +#### Multiple IP Addresses +Add multiple IP addresses to send the same packet to specific targets. The app will show you how many addresses received the packet successfully. + ### Sending UDP Packages 1. Navigate to a project 2. Find the package you want to send 3. Tap the **"Send"** button -4. The package will be sent to the configured IP address and port +4. The package will be sent to: + - All devices on the network (if broadcast mode is enabled) + - Multiple specific IP addresses (if multiple IPs are configured) + - A single IP address (standard mode) +5. Check the feedback message to confirm successful delivery ### Managing Projects and Packages @@ -103,6 +114,14 @@ The example demonstrates simple but practical commands like enabling/disabling o **Note:** For full UDP functionality, run the app on desktop (Windows/macOS/Linux) or mobile platforms. Web browsers do not support raw UDP sockets due to security restrictions. +### Broadcast Mode Requirements + +To use broadcast mode effectively: +- All target devices must be on the same local network (subnet) +- Your firewall must allow outbound UDP broadcast traffic +- Receiving applications must be listening on the specified port +- Some routers may block broadcast packets - check your router settings if needed + ## Architecture The app follows a clean architecture pattern with: diff --git a/lib/models/project.dart b/lib/models/project.dart index a7a1cc5..86678bc 100644 --- a/lib/models/project.dart +++ b/lib/models/project.dart @@ -7,34 +7,54 @@ part 'project.g.dart'; class Project { final String id; final String name; - final String ipAddress; + + // Support for multiple IP addresses at project level + final List ipAddresses; + + // Broadcast mode flag for project + @JsonKey(defaultValue: false) + final bool isBroadcast; + final int port; final List packages; + + // Legacy field for backward compatibility + @JsonKey(includeFromJson: true, includeToJson: false) + final String? ipAddress; Project({ required this.id, required this.name, - required this.ipAddress, + List? ipAddresses, + this.isBroadcast = false, required this.port, required this.packages, - }); + this.ipAddress, + }) : ipAddresses = ipAddresses ?? (ipAddress != null ? [ipAddress] : ['127.0.0.1']); - factory Project.fromJson(Map json) => - _$ProjectFromJson(json); + factory Project.fromJson(Map json) { + // Handle migration from old format + if (json.containsKey('ipAddress') && !json.containsKey('ipAddresses')) { + json['ipAddresses'] = [json['ipAddress']]; + } + return _$ProjectFromJson(json); + } Map toJson() => _$ProjectToJson(this); Project copyWith({ String? id, String? name, - String? ipAddress, + List? ipAddresses, + bool? isBroadcast, int? port, List? packages, }) { return Project( id: id ?? this.id, name: name ?? this.name, - ipAddress: ipAddress ?? this.ipAddress, + ipAddresses: ipAddresses ?? this.ipAddresses, + isBroadcast: isBroadcast ?? this.isBroadcast, port: port ?? this.port, packages: packages ?? this.packages, ); diff --git a/lib/models/project.g.dart b/lib/models/project.g.dart index 801c9d8..f13b1eb 100644 --- a/lib/models/project.g.dart +++ b/lib/models/project.g.dart @@ -9,17 +9,22 @@ part of 'project.dart'; Project _$ProjectFromJson(Map json) => Project( id: json['id'] as String, name: json['name'] as String, - ipAddress: json['ipAddress'] as String, + ipAddresses: (json['ipAddresses'] as List?) + ?.map((e) => e as String) + .toList(), + isBroadcast: json['isBroadcast'] as bool? ?? false, port: (json['port'] as num).toInt(), packages: (json['packages'] as List) .map((e) => UdpPackage.fromJson(e as Map)) .toList(), + ipAddress: json['ipAddress'] as String?, ); Map _$ProjectToJson(Project instance) => { 'id': instance.id, 'name': instance.name, - 'ipAddress': instance.ipAddress, + 'ipAddresses': instance.ipAddresses, + 'isBroadcast': instance.isBroadcast, 'port': instance.port, 'packages': instance.packages, }; diff --git a/lib/models/udp_package.dart b/lib/models/udp_package.dart index 405bdbd..f7e2971 100644 --- a/lib/models/udp_package.dart +++ b/lib/models/udp_package.dart @@ -7,17 +7,34 @@ class UdpPackage { final String id; final String name; final String data; - final String ipAddress; + + // Support for multiple IP addresses + final List ipAddresses; + + // Broadcast mode flag + @JsonKey(defaultValue: false) + final bool isBroadcast; + + // Legacy field for backward compatibility - kept for migration + @JsonKey(includeFromJson: true, includeToJson: false) + final String? ipAddress; UdpPackage({ required this.id, required this.name, required this.data, - required this.ipAddress, - }); + List? ipAddresses, + this.isBroadcast = false, + this.ipAddress, + }) : ipAddresses = ipAddresses ?? (ipAddress != null ? [ipAddress] : ['127.0.0.1']); - factory UdpPackage.fromJson(Map json) => - _$UdpPackageFromJson(json); + factory UdpPackage.fromJson(Map json) { + // Handle migration from old format + if (json.containsKey('ipAddress') && !json.containsKey('ipAddresses')) { + json['ipAddresses'] = [json['ipAddress']]; + } + return _$UdpPackageFromJson(json); + } Map toJson() => _$UdpPackageToJson(this); @@ -25,13 +42,15 @@ class UdpPackage { String? id, String? name, String? data, - String? ipAddress, + List? ipAddresses, + bool? isBroadcast, }) { return UdpPackage( id: id ?? this.id, name: name ?? this.name, data: data ?? this.data, - ipAddress: ipAddress ?? this.ipAddress, + ipAddresses: ipAddresses ?? this.ipAddresses, + isBroadcast: isBroadcast ?? this.isBroadcast, ); } } diff --git a/lib/models/udp_package.g.dart b/lib/models/udp_package.g.dart index cfa7cf9..293cedc 100644 --- a/lib/models/udp_package.g.dart +++ b/lib/models/udp_package.g.dart @@ -10,7 +10,11 @@ UdpPackage _$UdpPackageFromJson(Map json) => UdpPackage( id: json['id'] as String, name: json['name'] as String, data: json['data'] as String, - ipAddress: json['ipAddress'] as String, + ipAddresses: (json['ipAddresses'] as List?) + ?.map((e) => e as String) + .toList(), + isBroadcast: json['isBroadcast'] as bool? ?? false, + ipAddress: json['ipAddress'] as String?, ); Map _$UdpPackageToJson(UdpPackage instance) => @@ -18,5 +22,6 @@ Map _$UdpPackageToJson(UdpPackage instance) => 'id': instance.id, 'name': instance.name, 'data': instance.data, - 'ipAddress': instance.ipAddress, + 'ipAddresses': instance.ipAddresses, + 'isBroadcast': instance.isBroadcast, }; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 3918067..cfa8858 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -322,9 +322,14 @@ class _HomeScreenState extends State { ), const SizedBox(height: 4), Text( - '${project.ipAddress}:${project.port} • ${project.packages.length} package(s)', - style: - Theme.of(context).textTheme.bodySmall, + project.isBroadcast + ? 'Broadcast • Port: ${project.port} • ${project.packages.length} package(s)' + : project.ipAddresses.length > 1 + ? '${project.ipAddresses.length} IPs • Port: ${project.port} • ${project.packages.length} package(s)' + : '${project.ipAddresses.first}:${project.port} • ${project.packages.length} package(s)', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), diff --git a/lib/screens/project_screen.dart b/lib/screens/project_screen.dart index 0183808..78ab871 100644 --- a/lib/screens/project_screen.dart +++ b/lib/screens/project_screen.dart @@ -22,13 +22,17 @@ class _ProjectScreenState extends State { bool _isSending = false; late TextEditingController _ipController; late TextEditingController _portController; + late List _projectIpAddresses; + late bool _projectIsBroadcast; @override void initState() { super.initState(); _project = widget.project; - _ipController = TextEditingController(text: _project.ipAddress); + _ipController = TextEditingController(); _portController = TextEditingController(text: _project.port.toString()); + _projectIpAddresses = _project.ipAddresses.toList(); + _projectIsBroadcast = _project.isBroadcast; } @override @@ -67,7 +71,8 @@ class _ProjectScreenState extends State { id: DateTime.now().millisecondsSinceEpoch.toString(), name: '${package.name} (Copy)', data: package.data, - ipAddress: package.ipAddress, + ipAddresses: package.ipAddresses.toList(), + isBroadcast: package.isBroadcast, ); _addPackage(copiedPackage); ScaffoldMessenger.of(context).showSnackBar( @@ -78,58 +83,40 @@ class _ProjectScreenState extends State { ); } - void _updateSettings() { - final ip = _ipController.text.trim(); - final port = int.tryParse(_portController.text); - - // Validate IP address + bool _isValidIp(String ip) { final ipPattern = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$'); - bool isValidIp = ipPattern.hasMatch(ip); - if (isValidIp) { - final parts = ip.split('.'); - for (final part in parts) { - final num = int.tryParse(part); - if (num == null || num < 0 || num > 255) { - isValidIp = false; - break; - } + if (!ipPattern.hasMatch(ip)) return false; + + final parts = ip.split('.'); + for (final part in parts) { + final num = int.tryParse(part); + if (num == null || num < 0 || num > 255) { + return false; } } - - // Validate port - final isValidPort = port != null && port >= 1 && port <= 65535; - - if (isValidIp && isValidPort) { - setState(() { - _project = _project.copyWith( - ipAddress: ip, - port: port, + return true; + } + + void _addProjectIp() { + final ip = _ipController.text.trim(); + if (ip.isNotEmpty && _isValidIp(ip)) { + if (!_projectIpAddresses.contains(ip)) { + setState(() { + _projectIpAddresses.add(ip); + _ipController.clear(); + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('IP address already added'), + behavior: SnackBarBehavior.floating, + ), ); - }); + } + } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Settings updated successfully!'), - backgroundColor: Colors.green, - behavior: SnackBarBehavior.floating, - ), - ); - } else { - // Revert to current project values - _ipController.text = _project.ipAddress; - _portController.text = _project.port.toString(); - - String errorMessage = 'Invalid '; - if (!isValidIp && !isValidPort) { - errorMessage += 'IP address and port number'; - } else if (!isValidIp) { - errorMessage += 'IP address'; - } else { - errorMessage += 'port number (1-65535)'; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(errorMessage), + content: Text('Invalid IP address'), backgroundColor: Colors.red, behavior: SnackBarBehavior.floating, ), @@ -137,6 +124,117 @@ class _ProjectScreenState extends State { } } + void _removeProjectIp(String ip) { + setState(() { + _projectIpAddresses.remove(ip); + // Ensure at least one IP if not in broadcast mode + if (_projectIpAddresses.isEmpty && !_projectIsBroadcast) { + _projectIpAddresses.add('127.0.0.1'); + } + }); + } + + void _updateSettings() { + final port = int.tryParse(_portController.text); + + // Validate port + final isValidPort = port != null && port >= 1 && port <= 65535; + + // Validate we have IPs if not in broadcast mode + if (!_projectIsBroadcast && _projectIpAddresses.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please add at least one IP address'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + if (isValidPort) { + // Show dialog asking if user wants to apply settings to all packages + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Apply Settings'), + content: const Text( + 'Do you want to apply these settings to all packages?\n\n' + '• Yes - All packages will use these settings\n' + '• No - Only project defaults will be updated (packages keep their individual settings)', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + _applySettings(port!, applyToPackages: false); + }, + child: const Text('No'), + ), + FilledButton( + onPressed: () { + Navigator.pop(context); + _applySettings(port!, applyToPackages: true); + }, + child: const Text('Yes'), + ), + ], + ), + ); + } else { + // Revert to current project values + _portController.text = _project.port.toString(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid port number (1-65535)'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + void _applySettings(int port, {required bool applyToPackages}) { + setState(() { + if (applyToPackages) { + // Update all packages to use the new project settings + final updatedPackages = _project.packages.map((package) { + return package.copyWith( + ipAddresses: _projectIsBroadcast ? [] : _projectIpAddresses, + isBroadcast: _projectIsBroadcast, + ); + }).toList(); + + _project = _project.copyWith( + ipAddresses: _projectIsBroadcast ? [] : _projectIpAddresses, + isBroadcast: _projectIsBroadcast, + port: port, + packages: updatedPackages, + ); + } else { + // Only update project-level settings + _project = _project.copyWith( + ipAddresses: _projectIsBroadcast ? [] : _projectIpAddresses, + isBroadcast: _projectIsBroadcast, + port: port, + ); + } + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + applyToPackages + ? 'Settings applied to project and all packages!' + : 'Project settings updated!', + ), + backgroundColor: Colors.green, + behavior: SnackBarBehavior.floating, + ), + ); + } + void _showPackageDialog({UdpPackage? package}) { showDialog( context: context, @@ -170,15 +268,28 @@ class _ProjectScreenState extends State { _isSending = true; }); - final success = await _udpService.sendPackage( + // Determine if we should use project-level or package-level settings + // Packages can override project settings, or use project defaults if empty + final isBroadcast = package.isBroadcast || (_project.isBroadcast && package.ipAddresses.isEmpty); + final ipAddresses = package.ipAddresses.isNotEmpty + ? package.ipAddresses + : _project.ipAddresses; + + // Use the new advanced send method that supports broadcast and multiple IPs + final results = await _udpService.sendPackageAdvanced( data: package.data, - ipAddress: _project.ipAddress, // Use project IP to override package IP port: _project.port, + isBroadcast: isBroadcast, + ipAddresses: isBroadcast ? null : ipAddresses, ); + final successCount = results.values.where((v) => v).length; + final totalCount = results.length; + final allSuccess = successCount == totalCount; + setState(() { _isSending = false; - if (success) { + if (allSuccess) { _lastSentPackageId = package.id; // Reset the indicator after 2 seconds Future.delayed(const Duration(seconds: 2), () { @@ -193,15 +304,35 @@ class _ProjectScreenState extends State { if (!mounted) return; - if (success) { + // Show appropriate feedback based on results + if (allSuccess) { + String message; + if (isBroadcast) { + message = 'Package "${package.name}" broadcast successfully!'; + } else if (totalCount > 1) { + message = 'Package "${package.name}" sent to $totalCount addresses!'; + } else { + message = 'Package "${package.name}" sent successfully!'; + } + ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Package "${package.name}" sent successfully!'), + content: Text(message), backgroundColor: Colors.green, behavior: SnackBarBehavior.floating, ), ); + } else if (successCount > 0) { + // Partial success + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Package "${package.name}" sent to $successCount of $totalCount addresses'), + backgroundColor: Colors.orange, + behavior: SnackBarBehavior.floating, + ), + ); } else { + // Complete failure ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to send package "${package.name}"'), @@ -254,20 +385,106 @@ class _ProjectScreenState extends State { ], ), const SizedBox(height: 16), + + // Broadcast Toggle + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Broadcast Mode'), + subtitle: const Text('Send to all devices on network'), + value: _projectIsBroadcast, + onChanged: (value) { + setState(() { + _projectIsBroadcast = value; + }); + }, + secondary: const Icon(Icons.sensors), + ), + const SizedBox(height: 8), + + // IP Addresses Section (only shown when not in broadcast mode) + if (!_projectIsBroadcast) ...[ + Text( + 'Target IP Addresses', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + + // IP Address Input + Row( + children: [ + Expanded( + child: TextField( + controller: _ipController, + decoration: const InputDecoration( + labelText: 'IP Address', + prefixIcon: Icon(Icons.computer), + hintText: '192.168.1.100', + isDense: true, + ), + keyboardType: TextInputType.number, + onSubmitted: (_) => _addProjectIp(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _addProjectIp, + icon: const Icon(Icons.add), + tooltip: 'Add IP', + ), + ], + ), + const SizedBox(height: 12), + + // IP Address Chips + if (_projectIpAddresses.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: _projectIpAddresses.map((ip) { + return Chip( + label: Text(ip), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: _projectIpAddresses.length > 1 + ? () => _removeProjectIp(ip) + : null, + ); + }).toList(), + ), + const SizedBox(height: 16), + ] else ...[ + // Broadcast Info + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Packages will be broadcast to all devices on your local network', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Port Configuration Row( children: [ - Expanded( - child: TextField( - controller: _ipController, - decoration: const InputDecoration( - labelText: 'IP Address', - prefixIcon: Icon(Icons.computer), - isDense: true, - ), - onSubmitted: (_) => _updateSettings(), - ), - ), - const SizedBox(width: 8), Expanded( child: TextField( controller: _portController, @@ -401,11 +618,33 @@ class _ProjectScreenState extends State { ], ), const SizedBox(height: 4), - Text( - 'To: ${_project.ipAddress}:${_project.port}', - style: - Theme.of(context).textTheme.bodySmall, - ), + if (package.isBroadcast) + Row( + children: [ + Icon( + Icons.sensors, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + 'Broadcast • Port: ${_project.port}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ) + else + Text( + package.ipAddresses.length > 1 + ? 'To: ${package.ipAddresses.join(", ")} • Port: ${_project.port}' + : 'To: ${package.ipAddresses.first}:${_project.port}', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ], ), ), diff --git a/lib/services/udp_service.dart b/lib/services/udp_service.dart index 3fd0db2..b0f40ff 100644 --- a/lib/services/udp_service.dart +++ b/lib/services/udp_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:udp/udp.dart'; class UdpService { + /// Send a package to a single IP address Future sendPackage({ required String data, required String ipAddress, @@ -37,6 +38,98 @@ class UdpService { sender?.close(); } } + + /// Send a broadcast package to all devices on the network + Future sendBroadcast({ + required String data, + required int port, + String broadcastAddress = '255.255.255.255', + }) async { + UDP? sender; + try { + // Create UDP instance + sender = await UDP.bind(Endpoint.any()); + + // Convert data to bytes + final dataBytes = utf8.encode(data); + + // Parse broadcast address + final address = InternetAddress(broadcastAddress); + + // Create broadcast endpoint - Endpoint.broadcast takes only port + final destination = Endpoint.broadcast(port: Port(port)); + + // Send the broadcast packet + final bytesSent = await sender.send(dataBytes, destination); + + // Give a small delay to ensure packet is sent + await Future.delayed(const Duration(milliseconds: 100)); + + return bytesSent > 0; + } catch (e) { + // Error occurred while sending broadcast package + return false; + } finally { + // Always close the socket + sender?.close(); + } + } + + /// Send a package to multiple IP addresses + /// Returns a map of IP address to success status + Future> sendToMultipleIps({ + required String data, + required List ipAddresses, + required int port, + }) async { + final results = {}; + + for (final ip in ipAddresses) { + final success = await sendPackage( + data: data, + ipAddress: ip, + port: port, + ); + results[ip] = success; + } + + return results; + } + + /// Send a package with support for broadcast mode and multiple IPs + /// Returns a map of targets (IP addresses or "broadcast") to success status + Future> sendPackageAdvanced({ + required String data, + required int port, + bool isBroadcast = false, + List? ipAddresses, + String broadcastAddress = '255.255.255.255', + }) async { + if (isBroadcast) { + // Send broadcast + final success = await sendBroadcast( + data: data, + port: port, + broadcastAddress: broadcastAddress, + ); + return {'broadcast': success}; + } else if (ipAddresses != null && ipAddresses.isNotEmpty) { + // Send to multiple IPs + return await sendToMultipleIps( + data: data, + ipAddresses: ipAddresses, + port: port, + ); + } else { + // Fallback to localhost + final success = await sendPackage( + data: data, + ipAddress: '127.0.0.1', + port: port, + ); + return {'127.0.0.1': success}; + } + } } diff --git a/lib/widgets/package_dialog.dart b/lib/widgets/package_dialog.dart index 2c2964a..1aba3ab 100644 --- a/lib/widgets/package_dialog.dart +++ b/lib/widgets/package_dialog.dart @@ -20,15 +20,18 @@ class _PackageDialogState extends State { late TextEditingController _ipController; late TextEditingController _dataController; final _formKey = GlobalKey(); + + late bool _isBroadcast; + late List _ipAddresses; @override void initState() { super.initState(); _nameController = TextEditingController(text: widget.package?.name ?? ''); - _ipController = TextEditingController( - text: widget.package?.ipAddress ?? '127.0.0.1', - ); + _ipController = TextEditingController(); _dataController = TextEditingController(text: widget.package?.data ?? ''); + _isBroadcast = widget.package?.isBroadcast ?? false; + _ipAddresses = widget.package?.ipAddresses.toList() ?? ['127.0.0.1']; } @override @@ -49,12 +52,62 @@ class _PackageDialogState extends State { return true; } + void _addIpAddress() { + final ip = _ipController.text.trim(); + if (ip.isNotEmpty && _isValidIp(ip)) { + if (!_ipAddresses.contains(ip)) { + setState(() { + _ipAddresses.add(ip); + _ipController.clear(); + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('IP address already added'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid IP address'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + void _removeIpAddress(String ip) { + setState(() { + _ipAddresses.remove(ip); + // Ensure at least one IP if not in broadcast mode + if (_ipAddresses.isEmpty && !_isBroadcast) { + _ipAddresses.add('127.0.0.1'); + } + }); + } + void _save() { if (_formKey.currentState!.validate()) { + // Validate we have IPs if not in broadcast mode + if (!_isBroadcast && _ipAddresses.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please add at least one IP address'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + final package = UdpPackage( id: widget.package?.id ?? DateTime.now().millisecondsSinceEpoch.toString(), name: _nameController.text.trim(), - ipAddress: _ipController.text.trim(), + ipAddresses: _isBroadcast ? [] : _ipAddresses, + isBroadcast: _isBroadcast, data: _dataController.text, ); widget.onSave(package); @@ -71,7 +124,9 @@ class _PackageDialogState extends State { key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Package Name TextFormField( controller: _nameController, decoration: const InputDecoration( @@ -87,25 +142,103 @@ class _PackageDialogState extends State { autofocus: true, ), const SizedBox(height: 16), - TextFormField( - controller: _ipController, - decoration: const InputDecoration( - labelText: 'IP Address', - prefixIcon: Icon(Icons.computer), - hintText: '192.168.1.100', - ), - keyboardType: TextInputType.number, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter an IP address'; - } - if (!_isValidIp(value.trim())) { - return 'Invalid IP address'; - } - return null; + + // Broadcast Mode Toggle + SwitchListTile( + title: const Text('Broadcast Mode'), + subtitle: const Text('Send to all devices on network'), + value: _isBroadcast, + onChanged: (value) { + setState(() { + _isBroadcast = value; + }); }, + secondary: const Icon(Icons.sensors), ), - const SizedBox(height: 16), + const SizedBox(height: 8), + + // IP Addresses Section (only shown when not in broadcast mode) + if (!_isBroadcast) ...[ + Text( + 'Target IP Addresses', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + + // IP Address Input + Row( + children: [ + Expanded( + child: TextField( + controller: _ipController, + decoration: const InputDecoration( + labelText: 'IP Address', + prefixIcon: Icon(Icons.computer), + hintText: '192.168.1.100', + isDense: true, + ), + keyboardType: TextInputType.number, + onSubmitted: (_) => _addIpAddress(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _addIpAddress, + icon: const Icon(Icons.add), + tooltip: 'Add IP', + ), + ], + ), + const SizedBox(height: 12), + + // IP Address Chips + if (_ipAddresses.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: _ipAddresses.map((ip) { + return Chip( + label: Text(ip), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: _ipAddresses.length > 1 + ? () => _removeIpAddress(ip) + : null, + ); + }).toList(), + ), + const SizedBox(height: 8), + ] else ...[ + // Broadcast Info + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'This package will be broadcast to all devices on your local network', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Data Field TextFormField( controller: _dataController, decoration: const InputDecoration( diff --git a/lib/widgets/project_dialog.dart b/lib/widgets/project_dialog.dart index 6771815..bd127ad 100644 --- a/lib/widgets/project_dialog.dart +++ b/lib/widgets/project_dialog.dart @@ -27,7 +27,7 @@ class _ProjectDialogState extends State { super.initState(); _nameController = TextEditingController(text: widget.project?.name ?? ''); _ipController = TextEditingController( - text: widget.project?.ipAddress ?? '127.0.0.1', + text: widget.project?.ipAddresses.firstOrNull ?? '127.0.0.1', ); _portController = TextEditingController( text: widget.project?.port.toString() ?? '8888', @@ -47,7 +47,8 @@ class _ProjectDialogState extends State { final project = Project( id: widget.project?.id ?? DateTime.now().millisecondsSinceEpoch.toString(), name: _nameController.text.trim(), - ipAddress: _ipController.text.trim(), + ipAddresses: [_ipController.text.trim()], + isBroadcast: false, port: int.parse(_portController.text), packages: widget.project?.packages ?? [], );