multicast/broadcast support
This commit is contained in:
@ -31,9 +31,19 @@ For the example commands to work, create a simple test scene:
|
|||||||
|
|
||||||
The example includes these simple commands:
|
The example includes these simple commands:
|
||||||
|
|
||||||
|
### Standard Commands (Single IP)
|
||||||
- **Toggle Cube**: Enables/Disables the TestCube GameObject
|
- **Toggle Cube**: Enables/Disables the TestCube GameObject
|
||||||
- **Start Timer**: Starts a 10-second countdown timer
|
- **Start Timer**: Starts a 10-second countdown timer
|
||||||
- **Reset Scene**: Reloads the current scene
|
- **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
|
## 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`
|
1. Adding a new case in the `ProcessCommand` method in `UDPCommandListener.cs`
|
||||||
2. Creating a corresponding JSON entry in your project file
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
- **Commands not working**: Check that your firewall allows UDP traffic on the specified port
|
- **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)
|
- **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
|
- **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
|
||||||
|
|
||||||
|
|||||||
@ -75,6 +75,27 @@
|
|||||||
"name": "Change Color Green",
|
"name": "Change Color Green",
|
||||||
"data": "{\"command\":\"change_color\",\"value\":\"green\"}",
|
"data": "{\"command\":\"change_color\",\"value\":\"green\"}",
|
||||||
"ipAddress": "127.0.0.1"
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
23
README.md
23
README.md
@ -74,16 +74,27 @@ The example demonstrates simple but practical commands like enabling/disabling o
|
|||||||
2. Tap the **"New Package"** button
|
2. Tap the **"New Package"** button
|
||||||
3. Enter:
|
3. Enter:
|
||||||
- Package name (for identification)
|
- 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)
|
- Data (the content to send)
|
||||||
4. Tap **"Save"**
|
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
|
### Sending UDP Packages
|
||||||
|
|
||||||
1. Navigate to a project
|
1. Navigate to a project
|
||||||
2. Find the package you want to send
|
2. Find the package you want to send
|
||||||
3. Tap the **"Send"** button
|
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
|
### 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.
|
**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
|
## Architecture
|
||||||
|
|
||||||
The app follows a clean architecture pattern with:
|
The app follows a clean architecture pattern with:
|
||||||
|
|||||||
@ -7,34 +7,54 @@ part 'project.g.dart';
|
|||||||
class Project {
|
class Project {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
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 int port;
|
||||||
final List<UdpPackage> packages;
|
final List<UdpPackage> packages;
|
||||||
|
|
||||||
|
// Legacy field for backward compatibility
|
||||||
|
@JsonKey(includeFromJson: true, includeToJson: false)
|
||||||
|
final String? ipAddress;
|
||||||
|
|
||||||
Project({
|
Project({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.ipAddress,
|
List<String>? ipAddresses,
|
||||||
|
this.isBroadcast = false,
|
||||||
required this.port,
|
required this.port,
|
||||||
required this.packages,
|
required this.packages,
|
||||||
});
|
this.ipAddress,
|
||||||
|
}) : ipAddresses = ipAddresses ?? (ipAddress != null ? [ipAddress] : ['127.0.0.1']);
|
||||||
|
|
||||||
factory Project.fromJson(Map<String, dynamic> json) =>
|
factory Project.fromJson(Map<String, dynamic> json) {
|
||||||
_$ProjectFromJson(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);
|
Map<String, dynamic> toJson() => _$ProjectToJson(this);
|
||||||
|
|
||||||
Project copyWith({
|
Project copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? name,
|
String? name,
|
||||||
String? ipAddress,
|
List<String>? ipAddresses,
|
||||||
|
bool? isBroadcast,
|
||||||
int? port,
|
int? port,
|
||||||
List<UdpPackage>? packages,
|
List<UdpPackage>? packages,
|
||||||
}) {
|
}) {
|
||||||
return Project(
|
return Project(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
ipAddress: ipAddress ?? this.ipAddress,
|
ipAddresses: ipAddresses ?? this.ipAddresses,
|
||||||
|
isBroadcast: isBroadcast ?? this.isBroadcast,
|
||||||
port: port ?? this.port,
|
port: port ?? this.port,
|
||||||
packages: packages ?? this.packages,
|
packages: packages ?? this.packages,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,17 +9,22 @@ part of 'project.dart';
|
|||||||
Project _$ProjectFromJson(Map<String, dynamic> json) => Project(
|
Project _$ProjectFromJson(Map<String, dynamic> json) => Project(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
name: json['name'] 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(),
|
port: (json['port'] as num).toInt(),
|
||||||
packages: (json['packages'] as List<dynamic>)
|
packages: (json['packages'] as List<dynamic>)
|
||||||
.map((e) => UdpPackage.fromJson(e as Map<String, dynamic>))
|
.map((e) => UdpPackage.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
ipAddress: json['ipAddress'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ProjectToJson(Project instance) => <String, dynamic>{
|
Map<String, dynamic> _$ProjectToJson(Project instance) => <String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'ipAddress': instance.ipAddress,
|
'ipAddresses': instance.ipAddresses,
|
||||||
|
'isBroadcast': instance.isBroadcast,
|
||||||
'port': instance.port,
|
'port': instance.port,
|
||||||
'packages': instance.packages,
|
'packages': instance.packages,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,17 +7,34 @@ class UdpPackage {
|
|||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String data;
|
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({
|
UdpPackage({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.data,
|
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) =>
|
factory UdpPackage.fromJson(Map<String, dynamic> json) {
|
||||||
_$UdpPackageFromJson(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);
|
Map<String, dynamic> toJson() => _$UdpPackageToJson(this);
|
||||||
|
|
||||||
@ -25,13 +42,15 @@ class UdpPackage {
|
|||||||
String? id,
|
String? id,
|
||||||
String? name,
|
String? name,
|
||||||
String? data,
|
String? data,
|
||||||
String? ipAddress,
|
List<String>? ipAddresses,
|
||||||
|
bool? isBroadcast,
|
||||||
}) {
|
}) {
|
||||||
return UdpPackage(
|
return UdpPackage(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
data: data ?? this.data,
|
data: data ?? this.data,
|
||||||
ipAddress: ipAddress ?? this.ipAddress,
|
ipAddresses: ipAddresses ?? this.ipAddresses,
|
||||||
|
isBroadcast: isBroadcast ?? this.isBroadcast,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,11 @@ UdpPackage _$UdpPackageFromJson(Map<String, dynamic> json) => UdpPackage(
|
|||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
data: json['data'] 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) =>
|
Map<String, dynamic> _$UdpPackageToJson(UdpPackage instance) =>
|
||||||
@ -18,5 +22,6 @@ Map<String, dynamic> _$UdpPackageToJson(UdpPackage instance) =>
|
|||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'data': instance.data,
|
'data': instance.data,
|
||||||
'ipAddress': instance.ipAddress,
|
'ipAddresses': instance.ipAddresses,
|
||||||
|
'isBroadcast': instance.isBroadcast,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -322,9 +322,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${project.ipAddress}:${project.port} • ${project.packages.length} package(s)',
|
project.isBroadcast
|
||||||
style:
|
? 'Broadcast • Port: ${project.port} • ${project.packages.length} package(s)'
|
||||||
Theme.of(context).textTheme.bodySmall,
|
: 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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -22,13 +22,17 @@ class _ProjectScreenState extends State<ProjectScreen> {
|
|||||||
bool _isSending = false;
|
bool _isSending = false;
|
||||||
late TextEditingController _ipController;
|
late TextEditingController _ipController;
|
||||||
late TextEditingController _portController;
|
late TextEditingController _portController;
|
||||||
|
late List<String> _projectIpAddresses;
|
||||||
|
late bool _projectIsBroadcast;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_project = widget.project;
|
_project = widget.project;
|
||||||
_ipController = TextEditingController(text: _project.ipAddress);
|
_ipController = TextEditingController();
|
||||||
_portController = TextEditingController(text: _project.port.toString());
|
_portController = TextEditingController(text: _project.port.toString());
|
||||||
|
_projectIpAddresses = _project.ipAddresses.toList();
|
||||||
|
_projectIsBroadcast = _project.isBroadcast;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -67,7 +71,8 @@ class _ProjectScreenState extends State<ProjectScreen> {
|
|||||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
name: '${package.name} (Copy)',
|
name: '${package.name} (Copy)',
|
||||||
data: package.data,
|
data: package.data,
|
||||||
ipAddress: package.ipAddress,
|
ipAddresses: package.ipAddresses.toList(),
|
||||||
|
isBroadcast: package.isBroadcast,
|
||||||
);
|
);
|
||||||
_addPackage(copiedPackage);
|
_addPackage(copiedPackage);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@ -78,58 +83,40 @@ class _ProjectScreenState extends State<ProjectScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateSettings() {
|
bool _isValidIp(String ip) {
|
||||||
final ip = _ipController.text.trim();
|
|
||||||
final port = int.tryParse(_portController.text);
|
|
||||||
|
|
||||||
// Validate IP address
|
|
||||||
final ipPattern = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$');
|
final ipPattern = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$');
|
||||||
bool isValidIp = ipPattern.hasMatch(ip);
|
if (!ipPattern.hasMatch(ip)) return false;
|
||||||
if (isValidIp) {
|
|
||||||
final parts = ip.split('.');
|
final parts = ip.split('.');
|
||||||
for (final part in parts) {
|
for (final part in parts) {
|
||||||
final num = int.tryParse(part);
|
final num = int.tryParse(part);
|
||||||
if (num == null || num < 0 || num > 255) {
|
if (num == null || num < 0 || num > 255) {
|
||||||
isValidIp = false;
|
return false;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
// Validate port
|
}
|
||||||
final isValidPort = port != null && port >= 1 && port <= 65535;
|
|
||||||
|
void _addProjectIp() {
|
||||||
if (isValidIp && isValidPort) {
|
final ip = _ipController.text.trim();
|
||||||
setState(() {
|
if (ip.isNotEmpty && _isValidIp(ip)) {
|
||||||
_project = _project.copyWith(
|
if (!_projectIpAddresses.contains(ip)) {
|
||||||
ipAddress: ip,
|
setState(() {
|
||||||
port: port,
|
_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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Settings updated successfully!'),
|
content: Text('Invalid IP address'),
|
||||||
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),
|
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
behavior: SnackBarBehavior.floating,
|
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}) {
|
void _showPackageDialog({UdpPackage? package}) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -170,15 +268,28 @@ class _ProjectScreenState extends State<ProjectScreen> {
|
|||||||
_isSending = true;
|
_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,
|
data: package.data,
|
||||||
ipAddress: _project.ipAddress, // Use project IP to override package IP
|
|
||||||
port: _project.port,
|
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(() {
|
setState(() {
|
||||||
_isSending = false;
|
_isSending = false;
|
||||||
if (success) {
|
if (allSuccess) {
|
||||||
_lastSentPackageId = package.id;
|
_lastSentPackageId = package.id;
|
||||||
// Reset the indicator after 2 seconds
|
// Reset the indicator after 2 seconds
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
@ -193,15 +304,35 @@ class _ProjectScreenState extends State<ProjectScreen> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Package "${package.name}" sent successfully!'),
|
content: Text(message),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
behavior: SnackBarBehavior.floating,
|
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 {
|
} else {
|
||||||
|
// Complete failure
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to send package "${package.name}"'),
|
content: Text('Failed to send package "${package.name}"'),
|
||||||
@ -254,20 +385,106 @@ class _ProjectScreenState extends State<ProjectScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
Row(
|
||||||
children: [
|
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(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _portController,
|
controller: _portController,
|
||||||
@ -401,11 +618,33 @@ class _ProjectScreenState extends State<ProjectScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
if (package.isBroadcast)
|
||||||
'To: ${_project.ipAddress}:${_project.port}',
|
Row(
|
||||||
style:
|
children: [
|
||||||
Theme.of(context).textTheme.bodySmall,
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:udp/udp.dart';
|
import 'package:udp/udp.dart';
|
||||||
|
|
||||||
class UdpService {
|
class UdpService {
|
||||||
|
/// Send a package to a single IP address
|
||||||
Future<bool> sendPackage({
|
Future<bool> sendPackage({
|
||||||
required String data,
|
required String data,
|
||||||
required String ipAddress,
|
required String ipAddress,
|
||||||
@ -37,6 +38,98 @@ class UdpService {
|
|||||||
sender?.close();
|
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};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -20,15 +20,18 @@ class _PackageDialogState extends State<PackageDialog> {
|
|||||||
late TextEditingController _ipController;
|
late TextEditingController _ipController;
|
||||||
late TextEditingController _dataController;
|
late TextEditingController _dataController;
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
late bool _isBroadcast;
|
||||||
|
late List<String> _ipAddresses;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameController = TextEditingController(text: widget.package?.name ?? '');
|
_nameController = TextEditingController(text: widget.package?.name ?? '');
|
||||||
_ipController = TextEditingController(
|
_ipController = TextEditingController();
|
||||||
text: widget.package?.ipAddress ?? '127.0.0.1',
|
|
||||||
);
|
|
||||||
_dataController = TextEditingController(text: widget.package?.data ?? '');
|
_dataController = TextEditingController(text: widget.package?.data ?? '');
|
||||||
|
_isBroadcast = widget.package?.isBroadcast ?? false;
|
||||||
|
_ipAddresses = widget.package?.ipAddresses.toList() ?? ['127.0.0.1'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -49,12 +52,62 @@ class _PackageDialogState extends State<PackageDialog> {
|
|||||||
return true;
|
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() {
|
void _save() {
|
||||||
if (_formKey.currentState!.validate()) {
|
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(
|
final package = UdpPackage(
|
||||||
id: widget.package?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
id: widget.package?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
name: _nameController.text.trim(),
|
name: _nameController.text.trim(),
|
||||||
ipAddress: _ipController.text.trim(),
|
ipAddresses: _isBroadcast ? [] : _ipAddresses,
|
||||||
|
isBroadcast: _isBroadcast,
|
||||||
data: _dataController.text,
|
data: _dataController.text,
|
||||||
);
|
);
|
||||||
widget.onSave(package);
|
widget.onSave(package);
|
||||||
@ -71,7 +124,9 @@ class _PackageDialogState extends State<PackageDialog> {
|
|||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Package Name
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@ -87,25 +142,103 @@ class _PackageDialogState extends State<PackageDialog> {
|
|||||||
autofocus: true,
|
autofocus: true,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
|
||||||
controller: _ipController,
|
// Broadcast Mode Toggle
|
||||||
decoration: const InputDecoration(
|
SwitchListTile(
|
||||||
labelText: 'IP Address',
|
title: const Text('Broadcast Mode'),
|
||||||
prefixIcon: Icon(Icons.computer),
|
subtitle: const Text('Send to all devices on network'),
|
||||||
hintText: '192.168.1.100',
|
value: _isBroadcast,
|
||||||
),
|
onChanged: (value) {
|
||||||
keyboardType: TextInputType.number,
|
setState(() {
|
||||||
validator: (value) {
|
_isBroadcast = value;
|
||||||
if (value == null || value.trim().isEmpty) {
|
});
|
||||||
return 'Please enter an IP address';
|
|
||||||
}
|
|
||||||
if (!_isValidIp(value.trim())) {
|
|
||||||
return 'Invalid IP address';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
|
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(
|
TextFormField(
|
||||||
controller: _dataController,
|
controller: _dataController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class _ProjectDialogState extends State<ProjectDialog> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_nameController = TextEditingController(text: widget.project?.name ?? '');
|
_nameController = TextEditingController(text: widget.project?.name ?? '');
|
||||||
_ipController = TextEditingController(
|
_ipController = TextEditingController(
|
||||||
text: widget.project?.ipAddress ?? '127.0.0.1',
|
text: widget.project?.ipAddresses.firstOrNull ?? '127.0.0.1',
|
||||||
);
|
);
|
||||||
_portController = TextEditingController(
|
_portController = TextEditingController(
|
||||||
text: widget.project?.port.toString() ?? '8888',
|
text: widget.project?.port.toString() ?? '8888',
|
||||||
@ -47,7 +47,8 @@ class _ProjectDialogState extends State<ProjectDialog> {
|
|||||||
final project = Project(
|
final project = Project(
|
||||||
id: widget.project?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
id: widget.project?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
name: _nameController.text.trim(),
|
name: _nameController.text.trim(),
|
||||||
ipAddress: _ipController.text.trim(),
|
ipAddresses: [_ipController.text.trim()],
|
||||||
|
isBroadcast: false,
|
||||||
port: int.parse(_portController.text),
|
port: int.parse(_portController.text),
|
||||||
packages: widget.project?.packages ?? [],
|
packages: widget.project?.packages ?? [],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user