spotify and network groups working

This commit is contained in:
Leon Astner 2025-08-02 05:00:44 +02:00
parent 025eee7644
commit 6b93f1206d
11 changed files with 1381 additions and 267 deletions

View file

@ -1,4 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Network permissions for WiFi-based group features -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:label="sleepysound"
android:name="${applicationName}"

View file

@ -4,6 +4,7 @@ import 'pages/now_playing_page.dart';
import 'pages/voting_page.dart';
import 'pages/group_page.dart';
import 'services/music_queue_service.dart';
import 'services/network_group_service.dart';
void main() {
runApp(const MyApp());
@ -15,8 +16,11 @@ class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MusicQueueService(),
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => MusicQueueService()),
ChangeNotifierProvider(create: (context) => NetworkGroupService()),
],
child: MaterialApp(
title: 'SleepySound',
theme: ThemeData(

View file

@ -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<GroupPage> {
bool isConnectedToLido = true; // Simulate location verification
String userName = "Guest #${DateTime.now().millisecond}";
List<Map<String, dynamic>> 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<NetworkGroupService>(
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
? '<EFBFBD> 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'}';
}
}
}
}

View file

@ -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<String, dynamic> toJson() => {
'id': id,
'name': name,
'ipAddress': ipAddress,
'joinedAt': joinedAt.toIso8601String(),
'votes': votes,
'isOnline': isOnline,
'lastSeen': lastSeen.toIso8601String(),
};
factory NetworkUser.fromJson(Map<String, dynamic> 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<String, NetworkUser> _networkUsers = {};
late NetworkUser _currentUser;
// Getters
bool get isConnectedToWifi => _isConnectedToWifi;
String get currentNetworkName => _currentNetworkName;
String get currentNetworkSSID => _currentNetworkSSID;
String get localIpAddress => _localIpAddress;
List<NetworkUser> 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<void> _startNetworkMonitoring() async {
// Monitor connectivity changes
_connectivity.onConnectivityChanged.listen(_onConnectivityChanged);
// Initial connectivity check
await _checkConnectivity();
}
Future<void> _onConnectivityChanged(List<ConnectivityResult> results) async {
await _checkConnectivity();
}
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _discoverUsers() async {
if (_networkSubnet.isEmpty) return;
// Scan local network for other SleepySound users
final futures = <Future>[];
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<void> _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<void> _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<void> _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 = <String>[];
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<void> 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();
}
}

View file

@ -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<NetworkDemoWidget> createState() => _NetworkDemoWidgetState();
}
class _NetworkDemoWidgetState extends State<NetworkDemoWidget> {
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);
}
}

View file

@ -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'}';
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -6,9 +6,12 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
url_launcher_windows
)