import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/project.dart'; import '../models/udp_package.dart'; import '../services/udp_service.dart'; import '../widgets/package_dialog.dart'; class ProjectScreen extends StatefulWidget { final Project project; const ProjectScreen({super.key, required this.project}); @override State createState() => _ProjectScreenState(); } class _ProjectScreenState extends State { late Project _project; final UdpService _udpService = UdpService(); String? _lastSentPackageId; 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(); _portController = TextEditingController(text: _project.port.toString()); _projectIpAddresses = _project.ipAddresses.toList(); _projectIsBroadcast = _project.isBroadcast; } @override void dispose() { _ipController.dispose(); _portController.dispose(); super.dispose(); } void _addPackage(UdpPackage package) { setState(() { _project = _project.copyWith( packages: [..._project.packages, package], ); }); } void _updatePackage(UdpPackage updatedPackage) { setState(() { final packages = _project.packages.map((p) { return p.id == updatedPackage.id ? updatedPackage : p; }).toList(); _project = _project.copyWith(packages: packages); }); } void _deletePackage(String packageId) { setState(() { final packages = _project.packages.where((p) => p.id != packageId).toList(); _project = _project.copyWith(packages: packages); }); } void _copyPackage(UdpPackage package) { final copiedPackage = UdpPackage( id: DateTime.now().millisecondsSinceEpoch.toString(), name: '${package.name} (Copy)', data: package.data, ipAddresses: package.ipAddresses.toList(), isBroadcast: package.isBroadcast, ); _addPackage(copiedPackage); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Package copied successfully!'), behavior: SnackBarBehavior.floating, ), ); } bool _isValidIp(String ip) { final ipPattern = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$'); 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; } } 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('Invalid IP address'), backgroundColor: Colors.red, behavior: SnackBarBehavior.floating, ), ); } } 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, builder: (context) => PackageDialog( package: package, onSave: (newPackage) { if (package == null) { _addPackage(newPackage); } else { _updatePackage(newPackage); } }, ), ); } Future _sendPackage(UdpPackage package) async { if (kIsWeb) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('UDP is not supported in web browsers. Please run on desktop or mobile.'), backgroundColor: Colors.orange, behavior: SnackBarBehavior.floating, duration: Duration(seconds: 4), ), ); return; } setState(() { _isSending = true; }); // 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, 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 (allSuccess) { _lastSentPackageId = package.id; // Reset the indicator after 2 seconds Future.delayed(const Duration(seconds: 2), () { if (mounted && _lastSentPackageId == package.id) { setState(() { _lastSentPackageId = null; }); } }); } }); if (!mounted) return; // 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(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}"'), backgroundColor: Colors.red, behavior: SnackBarBehavior.floating, ), ); } } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (!didPop) { Navigator.of(context).pop(_project); } }, child: Scaffold( appBar: AppBar( title: Text(_project.name), ), body: Column( children: [ // Settings Card Container( width: double.infinity, margin: const EdgeInsets.all(16), child: Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.settings, size: 20, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Text( 'Project Settings', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), 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: _portController, decoration: const InputDecoration( labelText: 'UDP Port', prefixIcon: Icon(Icons.router), isDense: true, ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly ], onSubmitted: (_) => _updateSettings(), ), ), const SizedBox(width: 8), FilledButton.icon( onPressed: _updateSettings, icon: const Icon(Icons.check, size: 18), label: const Text('Apply'), ), ], ), ], ), ), ), ), // Packages List Expanded( child: _project.packages.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.inventory_2_outlined, size: 64, color: Theme.of(context).colorScheme.primary.withAlpha(128), ), const SizedBox(height: 16), Text( 'No packages yet', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( 'Create your first UDP package', style: Theme.of(context).textTheme.bodyMedium, ), ], ), ) : LayoutBuilder( builder: (context, constraints) { // Use 2 columns when width is >= 800px, otherwise 1 column final crossAxisCount = constraints.maxWidth >= 800 ? 2 : 1; final aspectRatio = constraints.maxWidth >= 800 ? 2.5 : 2.0; return GridView.builder( padding: const EdgeInsets.all(16), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, childAspectRatio: aspectRatio, crossAxisSpacing: 12, mainAxisSpacing: 12, ), itemCount: _project.packages.length, itemBuilder: (context, index) { final package = _project.packages[index]; final wasSent = _lastSentPackageId == package.id; return Card( color: wasSent ? Theme.of(context).colorScheme.primaryContainer : null, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( package.name, style: Theme.of(context) .textTheme .titleMedium ?.copyWith( fontWeight: FontWeight.bold, ), ), ), if (wasSent) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: Colors.green, borderRadius: BorderRadius.circular(12), ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.check, size: 14, color: Colors.white, ), SizedBox(width: 4), Text( 'Sent', style: TextStyle( color: Colors.white, fontSize: 12, ), ), ], ), ), ], ), const SizedBox(height: 4), 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, ), ], ), ), PopupMenuButton( itemBuilder: (context) => [ const PopupMenuItem( value: 'edit', child: Row( children: [ Icon(Icons.edit), SizedBox(width: 8), Text('Edit'), ], ), ), const PopupMenuItem( value: 'copy', child: Row( children: [ Icon(Icons.copy), SizedBox(width: 8), Text('Copy'), ], ), ), const PopupMenuItem( value: 'delete', child: Row( children: [ Icon(Icons.delete, color: Colors.red), SizedBox(width: 8), Text('Delete', style: TextStyle(color: Colors.red)), ], ), ), ], onSelected: (value) { if (value == 'edit') { _showPackageDialog(package: package); } else if (value == 'copy') { _copyPackage(package); } else if (value == 'delete') { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete Package'), content: Text( 'Are you sure you want to delete "${package.name}"?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( onPressed: () { _deletePackage(package.id); Navigator.pop(context); }, style: TextButton.styleFrom( foregroundColor: Colors.red, ), child: const Text('Delete'), ), ], ), ); } }, ), ], ), const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context) .colorScheme .surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Text( package.data, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, ), ), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _isSending ? null : () => _sendPackage(package), icon: _isSending ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.send), label: Text(_isSending ? 'Sending...' : 'Send'), ), ), ], ), ), ); }, ); }, ), ), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: () => _showPackageDialog(), icon: const Icon(Icons.add), label: const Text('New Package'), ), ), ); } }