diff --git a/CHALLENGE_2/sleepysound/android/app/src/main/AndroidManifest.xml b/CHALLENGE_2/sleepysound/android/app/src/main/AndroidManifest.xml index d86582e..b931811 100644 --- a/CHALLENGE_2/sleepysound/android/app/src/main/AndroidManifest.xml +++ b/CHALLENGE_2/sleepysound/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ + + + + + + + + MusicQueueService(), + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => MusicQueueService()), + ChangeNotifierProvider(create: (context) => NetworkGroupService()), + ], child: MaterialApp( title: 'SleepySound', theme: ThemeData( diff --git a/CHALLENGE_2/sleepysound/lib/pages/group_page.dart b/CHALLENGE_2/sleepysound/lib/pages/group_page.dart index e9ef176..36dd259 100644 --- a/CHALLENGE_2/sleepysound/lib/pages/group_page.dart +++ b/CHALLENGE_2/sleepysound/lib/pages/group_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../services/music_queue_service.dart'; +import '../services/network_group_service.dart'; +import '../widgets/network_demo_widget.dart'; class GroupPage extends StatefulWidget { const GroupPage({super.key}); @@ -10,314 +11,456 @@ class GroupPage extends StatefulWidget { } class _GroupPageState extends State { - bool isConnectedToLido = true; // Simulate location verification - String userName = "Guest #${DateTime.now().millisecond}"; - - List> activeUsers = [ - {"name": "Alex M.", "joined": "2 min ago", "votes": 5, "isOnline": true}, - {"name": "Sarah K.", "joined": "5 min ago", "votes": 3, "isOnline": true}, - {"name": "Marco R.", "joined": "8 min ago", "votes": 7, "isOnline": true}, - {"name": "Lisa F.", "joined": "12 min ago", "votes": 2, "isOnline": false}, - {"name": "Tom B.", "joined": "15 min ago", "votes": 4, "isOnline": true}, - ]; - @override Widget build(BuildContext context) { - return Container( - color: const Color(0xFF121212), - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Location Status Card - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: isConnectedToLido - ? const Color(0xFF22C55E).withOpacity(0.1) - : const Color(0xFFEF4444).withOpacity(0.1), - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: isConnectedToLido - ? const Color(0xFF22C55E) - : const Color(0xFFEF4444), - width: 1, - ), - ), - child: Column( - children: [ - Icon( - isConnectedToLido ? Icons.location_on : Icons.location_off, - size: 40, - color: isConnectedToLido - ? const Color(0xFF22C55E) - : const Color(0xFFEF4444), - ), - const SizedBox(height: 10), - Text( - isConnectedToLido - ? '📍 Connected to Lido Schenna' - : '❌ Not at Lido Schenna', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: isConnectedToLido + return Consumer( + builder: (context, networkService, child) { + final isConnected = networkService.isConnectedToWifi; + final networkName = networkService.currentNetworkName; + final currentUser = networkService.currentUser; + final networkUsers = networkService.networkUsers; + final onlineCount = networkService.onlineUsersCount; + + return Container( + color: const Color(0xFF121212), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // WiFi Connection Status Card + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isConnected + ? const Color(0xFF22C55E).withOpacity(0.1) + : const Color(0xFFEF4444).withOpacity(0.1), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: isConnected ? const Color(0xFF22C55E) : const Color(0xFFEF4444), + width: 1, ), ), - const SizedBox(height: 5), - Text( - isConnectedToLido - ? 'You can now vote and suggest music!' - : 'Please visit Lido Schenna to participate', - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - - const SizedBox(height: 25), - - // User Info Section - Container( - width: double.infinity, - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: const Color(0xFF1E1E1E), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - CircleAvatar( - radius: 25, - backgroundColor: const Color(0xFF6366F1), - child: Text( - userName.substring(0, 1), - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, + child: Column( + children: [ + Icon( + isConnected ? Icons.wifi : Icons.wifi_off, + size: 40, + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), ), - ), - ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - userName, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), + const SizedBox(height: 10), + Text( + isConnected + ? '� Connected to $networkName' + : '❌ Not connected to WiFi', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), ), - const SizedBox(height: 4), - const Text( - 'Active since 5 minutes ago', - style: TextStyle( + ), + const SizedBox(height: 5), + Text( + isConnected + ? 'You can now vote and suggest music with others on this network!' + : 'Please connect to WiFi to join the group session', + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + if (isConnected && networkService.localIpAddress.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'IP: ${networkService.localIpAddress}', + style: const TextStyle( color: Colors.grey, fontSize: 12, + fontFamily: 'monospace', ), ), ], - ), + ], ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFF6366F1).withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.how_to_vote, - color: Color(0xFF6366F1), - size: 14, - ), - SizedBox(width: 4), - Text( - '3 votes', - style: TextStyle( - color: Color(0xFF6366F1), - fontSize: 12, + ), + + const SizedBox(height: 25), + + // Current User Info Section + Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + CircleAvatar( + radius: 25, + backgroundColor: const Color(0xFF6366F1), + child: Text( + currentUser.name.substring(0, 1), + style: const TextStyle( + color: Colors.white, + fontSize: 20, fontWeight: FontWeight.bold, ), ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 25), - - // Active Users Section - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Active Guests', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF6366F1).withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${activeUsers.where((user) => user["isOnline"]).length} online', - style: const TextStyle( - color: Color(0xFF6366F1), - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - - const SizedBox(height: 15), - - // Users List - Expanded( - child: ListView.builder( - itemCount: activeUsers.length, - itemBuilder: (context, index) { - final user = activeUsers[index]; - return Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(15), - decoration: BoxDecoration( - color: const Color(0xFF1E1E1E), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Stack( + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 20, - backgroundColor: const Color(0xFF6366F1), - child: Text( - user["name"].toString().substring(0, 1), - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), + Text( + currentUser.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - if (user["isOnline"]) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: const Color(0xFF22C55E), - shape: BoxShape.circle, - border: Border.all(color: const Color(0xFF121212), width: 2), - ), - ), + const SizedBox(height: 4), + Text( + 'Active since ${_formatDuration(DateTime.now().difference(currentUser.joinedAt))} ago', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, ), + ), ], ), - const SizedBox(width: 15), - Expanded( + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.how_to_vote, + color: Color(0xFF6366F1), + size: 14, + ), + const SizedBox(width: 4), + Text( + '${currentUser.votes} votes', + style: const TextStyle( + color: Color(0xFF6366F1), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 25), + + // Demo Widget + NetworkDemoWidget(networkService: networkService), + + // Network Users Section + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Network Users', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$onlineCount online', + style: const TextStyle( + color: Color(0xFF6366F1), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + + const SizedBox(height: 15), + + // Users List + Expanded( + child: isConnected + ? networkUsers.isEmpty + ? const Center( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon( + Icons.search, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 16), Text( - user["name"], - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, + 'Searching for other users...', + style: TextStyle( + color: Colors.grey, fontSize: 16, ), ), - const SizedBox(height: 4), + SizedBox(height: 8), Text( - 'Joined ${user["joined"]}', - style: const TextStyle( + 'Make sure others are connected to the same WiFi network', + style: TextStyle( color: Colors.grey, fontSize: 12, ), + textAlign: TextAlign.center, ), ], ), + ) + : ListView.builder( + itemCount: networkUsers.length, + itemBuilder: (context, index) { + final user = networkUsers[index]; + final isCurrentUser = user.id == currentUser.id; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: isCurrentUser + ? const Color(0xFF6366F1).withOpacity(0.1) + : const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(12), + border: isCurrentUser + ? Border.all(color: const Color(0xFF6366F1), width: 1) + : null, + ), + child: Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: isCurrentUser + ? const Color(0xFF6366F1) + : const Color(0xFF4B5563), + child: Text( + user.name.substring(0, 1), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + if (user.isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF121212), width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + user.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + if (isCurrentUser) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF6366F1), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'You', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + 'Joined ${_formatDuration(DateTime.now().difference(user.joinedAt))} ago', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + if (!user.isOnline) ...[ + const Text( + ' • ', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + const Text( + 'Offline', + style: TextStyle( + color: Colors.red, + fontSize: 12, + ), + ), + ], + ], + ), + if (user.ipAddress.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'IP: ${user.ipAddress}', + style: const TextStyle( + color: Colors.grey, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${user.votes} votes', + style: const TextStyle( + color: Color(0xFF6366F1), + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + }, + ) + : const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.wifi_off, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'Connect to WiFi to join a group session', + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + ], ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF6366F1).withOpacity(0.2), + ), + ), + + // Bottom Action Buttons + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: isConnected ? () async { + // Refresh network discovery + await networkService.refreshNetwork(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Network refreshed! ✓'), + backgroundColor: Color(0xFF22C55E), + duration: Duration(seconds: 2), + ), + ); + } + } : null, + icon: const Icon(Icons.refresh), + label: const Text('Refresh Network'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), - child: Text( - '${user["votes"]} votes', - style: const TextStyle( - color: Color(0xFF6366F1), - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), ), - ], - ), - ); - }, - ), - ), - - // Bottom Action Buttons - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: isConnectedToLido ? () { - // Refresh location/connection - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Location verified! ✓'), - backgroundColor: Color(0xFF22C55E), - duration: Duration(seconds: 2), - ), - ); - } : null, - icon: const Icon(Icons.refresh), - label: const Text('Refresh Location'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6366F1), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), ), ), - ), + ], ), ], ), - ], - ), - ), + ), + ); + }, ); } -} + + String _formatDuration(Duration duration) { + if (duration.inMinutes < 1) { + return 'less than a minute'; + } else if (duration.inMinutes < 60) { + return '${duration.inMinutes} minute${duration.inMinutes == 1 ? '' : 's'}'; + } else { + final hours = duration.inHours; + final minutes = duration.inMinutes % 60; + if (minutes == 0) { + return '$hours hour${hours == 1 ? '' : 's'}'; + } else { + return '$hours hour${hours == 1 ? '' : 's'} $minutes minute${minutes == 1 ? '' : 's'}'; + } + } + } +} \ No newline at end of file diff --git a/CHALLENGE_2/sleepysound/lib/services/network_group_service.dart b/CHALLENGE_2/sleepysound/lib/services/network_group_service.dart new file mode 100644 index 0000000..c706210 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/network_group_service.dart @@ -0,0 +1,475 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:network_info_plus/network_info_plus.dart'; +import 'package:multicast_dns/multicast_dns.dart'; + +class NetworkUser { + final String id; + final String name; + final String ipAddress; + final DateTime joinedAt; + final int votes; + bool isOnline; + DateTime lastSeen; + + NetworkUser({ + required this.id, + required this.name, + required this.ipAddress, + required this.joinedAt, + this.votes = 0, + this.isOnline = true, + DateTime? lastSeen, + }) : lastSeen = lastSeen ?? DateTime.now(); + + Map toJson() => { + 'id': id, + 'name': name, + 'ipAddress': ipAddress, + 'joinedAt': joinedAt.toIso8601String(), + 'votes': votes, + 'isOnline': isOnline, + 'lastSeen': lastSeen.toIso8601String(), + }; + + factory NetworkUser.fromJson(Map json) => NetworkUser( + id: json['id'], + name: json['name'], + ipAddress: json['ipAddress'], + joinedAt: DateTime.parse(json['joinedAt']), + votes: json['votes'] ?? 0, + isOnline: json['isOnline'] ?? true, + lastSeen: DateTime.parse(json['lastSeen']), + ); +} + +class NetworkGroupService extends ChangeNotifier { + static const int _discoveryPort = 8089; + static const int _heartbeatInterval = 10; // seconds + + final Connectivity _connectivity = Connectivity(); + final NetworkInfo _networkInfo = NetworkInfo(); + MDnsClient? _mdnsClient; + HttpServer? _httpServer; + Timer? _heartbeatTimer; + Timer? _discoveryTimer; + + bool _isConnectedToWifi = false; + String _currentNetworkName = ''; + String _currentNetworkSSID = ''; + String _localIpAddress = ''; + String _networkSubnet = ''; + + final Map _networkUsers = {}; + late NetworkUser _currentUser; + + // Getters + bool get isConnectedToWifi => _isConnectedToWifi; + String get currentNetworkName => _currentNetworkName; + String get currentNetworkSSID => _currentNetworkSSID; + String get localIpAddress => _localIpAddress; + List get networkUsers => _networkUsers.values.toList(); + NetworkUser get currentUser => _currentUser; + int get onlineUsersCount => _networkUsers.values.where((u) => u.isOnline).length; + + NetworkGroupService() { + _initializeCurrentUser(); + _startNetworkMonitoring(); + } + + void _initializeCurrentUser() { + final random = Random(); + final guestNames = ['Alex', 'Sarah', 'Marco', 'Lisa', 'Tom', 'Anna', 'David', 'Emma']; + final randomName = guestNames[random.nextInt(guestNames.length)]; + final randomId = random.nextInt(999); + + _currentUser = NetworkUser( + id: 'user_${DateTime.now().millisecondsSinceEpoch}', + name: '$randomName #$randomId', + ipAddress: _localIpAddress, + joinedAt: DateTime.now(), + ); + } + + Future _startNetworkMonitoring() async { + // Monitor connectivity changes + _connectivity.onConnectivityChanged.listen(_onConnectivityChanged); + + // Initial connectivity check + await _checkConnectivity(); + } + + Future _onConnectivityChanged(List results) async { + await _checkConnectivity(); + } + + Future _checkConnectivity() async { + final connectivityResult = await _connectivity.checkConnectivity(); + final wasConnected = _isConnectedToWifi; + + _isConnectedToWifi = connectivityResult.contains(ConnectivityResult.wifi); + + if (_isConnectedToWifi) { + await _getNetworkInfo(); + if (!wasConnected) { + await _startNetworkServices(); + } + } else { + if (wasConnected) { + await _stopNetworkServices(); + } + _clearNetworkInfo(); + } + + notifyListeners(); + } + + Future _getNetworkInfo() async { + try { + _currentNetworkSSID = await _networkInfo.getWifiName() ?? 'Unknown Network'; + _currentNetworkName = _currentNetworkSSID.replaceAll('"', ''); // Remove quotes + _localIpAddress = await _networkInfo.getWifiIP() ?? ''; + + // Calculate network subnet (assuming /24) + if (_localIpAddress.isNotEmpty) { + final parts = _localIpAddress.split('.'); + if (parts.length == 4) { + _networkSubnet = '${parts[0]}.${parts[1]}.${parts[2]}'; + } + } + + // Update current user's IP + _currentUser = NetworkUser( + id: _currentUser.id, + name: _currentUser.name, + ipAddress: _localIpAddress, + joinedAt: _currentUser.joinedAt, + votes: _currentUser.votes, + ); + } catch (e) { + if (kDebugMode) { + print('Error getting network info: $e'); + } + } + } + + void _clearNetworkInfo() { + _currentNetworkName = ''; + _currentNetworkSSID = ''; + _localIpAddress = ''; + _networkSubnet = ''; + _networkUsers.clear(); + } + + Future _startNetworkServices() async { + if (!_isConnectedToWifi || _localIpAddress.isEmpty) return; + + try { + // Start mDNS client for service discovery + _mdnsClient = MDnsClient(); + await _mdnsClient!.start(); + + // Start HTTP server for peer communication + await _startHttpServer(); + + // Start announcing our service + await _announceService(); + + // Start discovering other users + _startDiscovery(); + + // Start heartbeat for keeping users online + _startHeartbeat(); + + // Add ourselves to the users list + _networkUsers[_currentUser.id] = _currentUser; + + } catch (e) { + if (kDebugMode) { + print('Error starting network services: $e'); + } + } + } + + Future _stopNetworkServices() async { + // Stop timers + _heartbeatTimer?.cancel(); + _discoveryTimer?.cancel(); + + // Stop HTTP server + await _httpServer?.close(); + _httpServer = null; + + // Stop mDNS client + _mdnsClient?.stop(); + _mdnsClient = null; + + // Clear users + _networkUsers.clear(); + } + + Future _startHttpServer() async { + try { + _httpServer = await HttpServer.bind(InternetAddress.anyIPv4, _discoveryPort); + _httpServer!.listen((request) async { + await _handleHttpRequest(request); + }); + } catch (e) { + if (kDebugMode) { + print('Error starting HTTP server: $e'); + } + } + } + + Future _handleHttpRequest(HttpRequest request) async { + try { + final response = request.response; + response.headers.set('Content-Type', 'application/json'); + response.headers.set('Access-Control-Allow-Origin', '*'); + + if (request.method == 'GET' && request.uri.path == '/user') { + // Return current user info + response.write(jsonEncode(_currentUser.toJson())); + } else if (request.method == 'POST' && request.uri.path == '/heartbeat') { + // Handle heartbeat from other users + final body = await utf8.decoder.bind(request).join(); + final userData = jsonDecode(body); + final user = NetworkUser.fromJson(userData); + + _networkUsers[user.id] = user; + notifyListeners(); + + response.write(jsonEncode({'status': 'ok'})); + } else { + response.statusCode = 404; + response.write(jsonEncode({'error': 'Not found'})); + } + + await response.close(); + } catch (e) { + if (kDebugMode) { + print('Error handling HTTP request: $e'); + } + } + } + + Future _announceService() async { + if (_mdnsClient == null || _localIpAddress.isEmpty) return; + + try { + // This would announce our service via mDNS + // In a real implementation, you'd use proper mDNS announcements + if (kDebugMode) { + print('Announcing service on $_localIpAddress:$_discoveryPort'); + } + } catch (e) { + if (kDebugMode) { + print('Error announcing service: $e'); + } + } + } + + void _startDiscovery() { + _discoveryTimer = Timer.periodic(const Duration(seconds: 15), (_) async { + await _discoverUsers(); + }); + + // Initial discovery + _discoverUsers(); + } + + Future _discoverUsers() async { + if (_networkSubnet.isEmpty) return; + + // Scan local network for other SleepySound users + final futures = []; + + for (int i = 1; i <= 254; i++) { + final ip = '$_networkSubnet.$i'; + if (ip != _localIpAddress) { + futures.add(_tryConnectToUser(ip)); + } + } + + // Wait for all connection attempts (with timeout) + await Future.wait(futures).timeout( + const Duration(seconds: 10), + onTimeout: () => [], + ); + } + + Future _tryConnectToUser(String ip) async { + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 2); + + final request = await client.getUrl(Uri.parse('http://$ip:$_discoveryPort/user')); + final response = await request.close().timeout(const Duration(seconds: 2)); + + if (response.statusCode == 200) { + final body = await utf8.decoder.bind(response).join(); + final userData = jsonDecode(body); + final user = NetworkUser.fromJson(userData); + + if (user.id != _currentUser.id) { + _networkUsers[user.id] = user; + notifyListeners(); + } + } + + client.close(); + } catch (e) { + // Ignore connection errors (expected for non-SleepySound devices) + } + } + + void _startHeartbeat() { + _heartbeatTimer = Timer.periodic(const Duration(seconds: _heartbeatInterval), (_) async { + await _sendHeartbeat(); + _cleanupOfflineUsers(); + }); + } + + Future _sendHeartbeat() async { + final heartbeatData = jsonEncode(_currentUser.toJson()); + + // Send heartbeat to all known users + final futures = _networkUsers.values + .where((user) => user.id != _currentUser.id && user.isOnline) + .map((user) => _sendHeartbeatToUser(user.ipAddress, heartbeatData)); + + await Future.wait(futures); + } + + Future _sendHeartbeatToUser(String ip, String data) async { + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 2); + + final request = await client.postUrl(Uri.parse('http://$ip:$_discoveryPort/heartbeat')); + request.headers.set('Content-Type', 'application/json'); + request.write(data); + + await request.close().timeout(const Duration(seconds: 2)); + client.close(); + } catch (e) { + // Mark user as potentially offline + final user = _networkUsers.values.firstWhere( + (u) => u.ipAddress == ip, + orElse: () => NetworkUser(id: '', name: '', ipAddress: '', joinedAt: DateTime.now()), + ); + if (user.id.isNotEmpty) { + user.isOnline = false; + notifyListeners(); + } + } + } + + void _cleanupOfflineUsers() { + final now = DateTime.now(); + final usersToRemove = []; + + for (final user in _networkUsers.values) { + if (user.id != _currentUser.id) { + final timeSinceLastSeen = now.difference(user.lastSeen).inSeconds; + if (timeSinceLastSeen > _heartbeatInterval * 3) { + user.isOnline = false; + // Remove users that have been offline for more than 5 minutes + if (timeSinceLastSeen > 300) { + usersToRemove.add(user.id); + } + } + } + } + + for (final userId in usersToRemove) { + _networkUsers.remove(userId); + } + + if (usersToRemove.isNotEmpty) { + notifyListeners(); + } + } + + // Public methods for UI interaction + Future refreshNetwork() async { + await _checkConnectivity(); + if (_isConnectedToWifi) { + await _discoverUsers(); + } + } + + void updateUserVotes(int votes) { + _currentUser = NetworkUser( + id: _currentUser.id, + name: _currentUser.name, + ipAddress: _currentUser.ipAddress, + joinedAt: _currentUser.joinedAt, + votes: votes, + isOnline: true, + ); + _networkUsers[_currentUser.id] = _currentUser; + notifyListeners(); + } + + String getConnectionStatus() { + if (!_isConnectedToWifi) { + return 'Not connected to WiFi'; + } + if (_currentNetworkName.isEmpty) { + return 'Connected to WiFi'; + } + return 'Connected to $_currentNetworkName'; + } + + // Demo methods for testing + void simulateNetworkConnection() { + _isConnectedToWifi = true; + _currentNetworkName = 'Demo WiFi Network'; + _currentNetworkSSID = '"Demo WiFi Network"'; + _localIpAddress = '192.168.1.100'; + _networkSubnet = '192.168.1'; + + // Update current user with new IP + _currentUser = NetworkUser( + id: _currentUser.id, + name: _currentUser.name, + ipAddress: _localIpAddress, + joinedAt: _currentUser.joinedAt, + votes: _currentUser.votes, + ); + _networkUsers[_currentUser.id] = _currentUser; + + notifyListeners(); + } + + void clearDemoUsers() { + _networkUsers.clear(); + _networkUsers[_currentUser.id] = _currentUser; + notifyListeners(); + } + + void addDemoUser(String name, int votes) { + final user = NetworkUser( + id: 'demo_${DateTime.now().millisecondsSinceEpoch}_${name.hashCode}', + name: name, + ipAddress: '192.168.1.${101 + _networkUsers.length}', + joinedAt: DateTime.now().subtract(Duration(minutes: (votes * 2))), + votes: votes, + isOnline: true, + ); + + _networkUsers[user.id] = user; + notifyListeners(); + } + + @override + void dispose() { + _stopNetworkServices(); + super.dispose(); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/widgets/network_demo_widget.dart b/CHALLENGE_2/sleepysound/lib/widgets/network_demo_widget.dart new file mode 100644 index 0000000..25a88c4 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/widgets/network_demo_widget.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import '../services/network_group_service.dart'; + +class NetworkDemoWidget extends StatefulWidget { + final NetworkGroupService networkService; + + const NetworkDemoWidget({ + super.key, + required this.networkService, + }); + + @override + State createState() => _NetworkDemoWidgetState(); +} + +class _NetworkDemoWidgetState extends State { + bool _isDemoMode = false; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF2D2D2D), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF6366F1), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.developer_mode, + color: Color(0xFF6366F1), + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Demo Mode', + style: TextStyle( + color: Color(0xFF6366F1), + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const Spacer(), + Switch( + value: _isDemoMode, + onChanged: (value) { + setState(() { + _isDemoMode = value; + }); + _toggleDemoMode(value); + }, + activeColor: const Color(0xFF6366F1), + ), + ], + ), + if (_isDemoMode) ...[ + const SizedBox(height: 12), + const Text( + 'Demo mode simulates network connectivity and adds sample users for testing.', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _addDemoUser, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1).withOpacity(0.2), + foregroundColor: const Color(0xFF6366F1), + elevation: 0, + ), + child: const Text('Add Demo User'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: _simulateVote, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF22C55E).withOpacity(0.2), + foregroundColor: const Color(0xFF22C55E), + elevation: 0, + ), + child: const Text('Simulate Vote'), + ), + ), + ], + ), + ], + ], + ), + ); + } + + void _toggleDemoMode(bool enabled) { + if (enabled) { + // Simulate network connection + widget.networkService.simulateNetworkConnection(); + _addInitialDemoUsers(); + } else { + // Clear demo users + widget.networkService.clearDemoUsers(); + } + } + + void _addInitialDemoUsers() { + final demoUsers = [ + {'name': 'Alex M.', 'votes': 5}, + {'name': 'Sarah K.', 'votes': 3}, + {'name': 'Marco R.', 'votes': 7}, + {'name': 'Lisa F.', 'votes': 2}, + ]; + + for (var user in demoUsers) { + widget.networkService.addDemoUser(user['name'] as String, user['votes'] as int); + } + } + + void _addDemoUser() { + final names = ['Tom B.', 'Emma W.', 'David L.', 'Anna K.', 'Mike R.', 'Julia S.']; + final randomName = names[DateTime.now().millisecond % names.length]; + final randomVotes = (DateTime.now().millisecond % 10) + 1; + + widget.networkService.addDemoUser(randomName, randomVotes); + } + + void _simulateVote() { + widget.networkService.updateUserVotes(widget.networkService.currentUser.votes + 1); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/widgets/network_widgets.dart b/CHALLENGE_2/sleepysound/lib/widgets/network_widgets.dart new file mode 100644 index 0000000..ab3074e --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/widgets/network_widgets.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import '../services/network_group_service.dart'; + +class NetworkStatusCard extends StatelessWidget { + final NetworkGroupService networkService; + + const NetworkStatusCard({ + super.key, + required this.networkService, + }); + + @override + Widget build(BuildContext context) { + final isConnected = networkService.isConnectedToWifi; + final networkName = networkService.currentNetworkName; + final localIp = networkService.localIpAddress; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isConnected + ? const Color(0xFF22C55E).withOpacity(0.1) + : const Color(0xFFEF4444).withOpacity(0.1), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), + width: 1, + ), + ), + child: Column( + children: [ + Icon( + isConnected ? Icons.wifi : Icons.wifi_off, + size: 40, + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), + ), + const SizedBox(height: 10), + Text( + isConnected + ? '📶 Connected to $networkName' + : '❌ Not connected to WiFi', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), + ), + ), + const SizedBox(height: 5), + Text( + isConnected + ? 'You can now vote and suggest music with others on this network!' + : 'Please connect to WiFi to join the group session', + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + if (isConnected && localIp.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Your IP: $localIp', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ); + } +} + +class UserCard extends StatelessWidget { + final NetworkUser user; + final bool isCurrentUser; + + const UserCard({ + super.key, + required this.user, + this.isCurrentUser = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: isCurrentUser + ? const Color(0xFF6366F1).withOpacity(0.1) + : const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(12), + border: isCurrentUser + ? Border.all(color: const Color(0xFF6366F1), width: 1) + : null, + ), + child: Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: isCurrentUser + ? const Color(0xFF6366F1) + : const Color(0xFF4B5563), + child: Text( + user.name.substring(0, 1), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + if (user.isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF121212), width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + user.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + if (isCurrentUser) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF6366F1), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'You', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + 'Joined ${_formatDuration(DateTime.now().difference(user.joinedAt))} ago', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + if (!user.isOnline) ...[ + const Text( + ' • ', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + const Text( + 'Offline', + style: TextStyle( + color: Colors.red, + fontSize: 12, + ), + ), + ], + ], + ), + if (user.ipAddress.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'IP: ${user.ipAddress}', + style: const TextStyle( + color: Colors.grey, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${user.votes} votes', + style: const TextStyle( + color: Color(0xFF6366F1), + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + + String _formatDuration(Duration duration) { + if (duration.inMinutes < 1) { + return 'less than a minute'; + } else if (duration.inMinutes < 60) { + return '${duration.inMinutes} minute${duration.inMinutes == 1 ? '' : 's'}'; + } else { + final hours = duration.inHours; + final minutes = duration.inMinutes % 60; + if (minutes == 0) { + return '$hours hour${hours == 1 ? '' : 's'}'; + } else { + return '$hours hour${hours == 1 ? '' : 's'} $minutes minute${minutes == 1 ? '' : 's'}'; + } + } + } +} diff --git a/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift b/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift index 53bcf14..8346026 100644 --- a/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,15 @@ import FlutterMacOS import Foundation +import connectivity_plus +import network_info_plus import shared_preferences_foundation import url_launcher_macos import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) diff --git a/CHALLENGE_2/sleepysound/pubspec.lock b/CHALLENGE_2/sleepysound/pubspec.lock index fafc5d2..734b970 100644 --- a/CHALLENGE_2/sleepysound/pubspec.lock +++ b/CHALLENGE_2/sleepysound/pubspec.lock @@ -145,6 +145,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "051849e2bd7c7b3bc5844ea0d096609ddc3a859890ec3a9ac4a65a2620cc1f99" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" convert: dependency: transitive description: @@ -177,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" fake_async: dependency: transitive description: @@ -384,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + multicast_dns: + dependency: "direct main" + description: + name: multicast_dns + sha256: de72ada5c3db6fdd6ad4ae99452fe05fb403c4bb37c67ceb255ddd37d2b5b1eb + url: "https://pub.dev" + source: hosted + version: "0.3.3" nested: dependency: transitive description: @@ -392,6 +424,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + network_info_plus: + dependency: "direct main" + description: + name: network_info_plus + sha256: "5bd4b86e28fed5ed4e6ac7764133c031dfb7d3f46aa2a81b46f55038aa78ecc0" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + network_info_plus_platform_interface: + dependency: transitive + description: + name: network_info_plus_platform_interface + sha256: "7e7496a8a9d8136859b8881affc613c4a21304afeb6c324bcefc4bd0aff6b94b" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" package_config: dependency: transitive description: @@ -432,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" platform: dependency: transitive description: @@ -789,6 +853,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.22.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" xdg_directories: dependency: transitive description: @@ -797,6 +869,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: diff --git a/CHALLENGE_2/sleepysound/pubspec.yaml b/CHALLENGE_2/sleepysound/pubspec.yaml index ccc4c22..e4da814 100644 --- a/CHALLENGE_2/sleepysound/pubspec.yaml +++ b/CHALLENGE_2/sleepysound/pubspec.yaml @@ -52,6 +52,13 @@ dependencies: # State management provider: ^6.1.1 + + # Network and WiFi connectivity + connectivity_plus: ^6.0.5 + network_info_plus: ^5.0.3 + + # Local network discovery + multicast_dns: ^0.3.2+4 dev_dependencies: flutter_test: diff --git a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc index 4f78848..5777988 100644 --- a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc +++ b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake index 88b22e5..3103206 100644 --- a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake +++ b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus url_launcher_windows )