multicast/broadcast support

This commit is contained in:
2025-10-15 15:22:51 +02:00
parent eb154802b4
commit c5264186f3
12 changed files with 712 additions and 116 deletions

View File

@ -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

View File

@ -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
}
]
}

View File

@ -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:

View File

@ -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<String> ipAddresses;
// Broadcast mode flag for project
@JsonKey(defaultValue: false)
final bool isBroadcast;
final int port;
final List<UdpPackage> 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<String>? 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<String, dynamic> json) =>
_$ProjectFromJson(json);
factory Project.fromJson(Map<String, dynamic> json) {
// Handle migration from old format
if (json.containsKey('ipAddress') && !json.containsKey('ipAddresses')) {
json['ipAddresses'] = [json['ipAddress']];
}
return _$ProjectFromJson(json);
}
Map<String, dynamic> toJson() => _$ProjectToJson(this);
Project copyWith({
String? id,
String? name,
String? ipAddress,
List<String>? ipAddresses,
bool? isBroadcast,
int? port,
List<UdpPackage>? 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,
);

View File

@ -9,17 +9,22 @@ part of 'project.dart';
Project _$ProjectFromJson(Map<String, dynamic> json) => Project(
id: json['id'] as String,
name: json['name'] as String,
ipAddress: json['ipAddress'] as String,
ipAddresses: (json['ipAddresses'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
isBroadcast: json['isBroadcast'] as bool? ?? false,
port: (json['port'] as num).toInt(),
packages: (json['packages'] as List<dynamic>)
.map((e) => UdpPackage.fromJson(e as Map<String, dynamic>))
.toList(),
ipAddress: json['ipAddress'] as String?,
);
Map<String, dynamic> _$ProjectToJson(Project instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'ipAddress': instance.ipAddress,
'ipAddresses': instance.ipAddresses,
'isBroadcast': instance.isBroadcast,
'port': instance.port,
'packages': instance.packages,
};

View File

@ -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<String> 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<String>? ipAddresses,
this.isBroadcast = false,
this.ipAddress,
}) : ipAddresses = ipAddresses ?? (ipAddress != null ? [ipAddress] : ['127.0.0.1']);
factory UdpPackage.fromJson(Map<String, dynamic> json) =>
_$UdpPackageFromJson(json);
factory UdpPackage.fromJson(Map<String, dynamic> json) {
// Handle migration from old format
if (json.containsKey('ipAddress') && !json.containsKey('ipAddresses')) {
json['ipAddresses'] = [json['ipAddress']];
}
return _$UdpPackageFromJson(json);
}
Map<String, dynamic> toJson() => _$UdpPackageToJson(this);
@ -25,13 +42,15 @@ class UdpPackage {
String? id,
String? name,
String? data,
String? ipAddress,
List<String>? 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,
);
}
}

View File

@ -10,7 +10,11 @@ UdpPackage _$UdpPackageFromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => e as String)
.toList(),
isBroadcast: json['isBroadcast'] as bool? ?? false,
ipAddress: json['ipAddress'] as String?,
);
Map<String, dynamic> _$UdpPackageToJson(UdpPackage instance) =>
@ -18,5 +22,6 @@ Map<String, dynamic> _$UdpPackageToJson(UdpPackage instance) =>
'id': instance.id,
'name': instance.name,
'data': instance.data,
'ipAddress': instance.ipAddress,
'ipAddresses': instance.ipAddresses,
'isBroadcast': instance.isBroadcast,
};

View File

@ -322,9 +322,14 @@ class _HomeScreenState extends State<HomeScreen> {
),
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,
),
],
),

View File

@ -22,13 +22,17 @@ class _ProjectScreenState extends State<ProjectScreen> {
bool _isSending = false;
late TextEditingController _ipController;
late TextEditingController _portController;
late List<String> _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<ProjectScreen> {
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<ProjectScreen> {
);
}
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<ProjectScreen> {
}
}
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<ProjectScreen> {
_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<ProjectScreen> {
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<ProjectScreen> {
],
),
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<ProjectScreen> {
],
),
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,
),
],
),
),

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:udp/udp.dart';
class UdpService {
/// Send a package to a single IP address
Future<bool> 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<bool> 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<Map<String, bool>> sendToMultipleIps({
required String data,
required List<String> ipAddresses,
required int port,
}) async {
final results = <String, bool>{};
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<Map<String, bool>> sendPackageAdvanced({
required String data,
required int port,
bool isBroadcast = false,
List<String>? 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};
}
}
}

View File

@ -20,15 +20,18 @@ class _PackageDialogState extends State<PackageDialog> {
late TextEditingController _ipController;
late TextEditingController _dataController;
final _formKey = GlobalKey<FormState>();
late bool _isBroadcast;
late List<String> _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<PackageDialog> {
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<PackageDialog> {
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<PackageDialog> {
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(

View File

@ -27,7 +27,7 @@ class _ProjectDialogState extends State<ProjectDialog> {
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<ProjectDialog> {
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 ?? [],
);