Compare commits

...
Sign in to create a new pull request.

11 commits

73 changed files with 5935 additions and 67 deletions

View file

@ -1,3 +1,8 @@
# API Secret Spotify
SPOTIFY_SECRET.dart
# Miscellaneous
*.class
*.log

View file

@ -0,0 +1,133 @@
# 🎵 SleepySound - Spotify Integration Setup
## Quick Start
The app now loads Spotify credentials from `lib/services/SPOTIFY_SECRET.dart`. You have two options:
### Option 1: Demo Mode (Works Immediately)
- The app works perfectly with realistic demo data
- No setup required - just run the app!
- All features work: search, voting, queue management
### Option 2: Real Spotify Integration
1. **Get Spotify API Credentials:**
- Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
- Create a new app called "SleepySound"
- Copy your Client ID and Client Secret
2. **Update the Secret File:**
```dart
// In lib/services/SPOTIFY_SECRET.dart
class SpotifyCredentials {
static const String clientId = 'your_actual_client_id_here';
static const String clientSecret = 'your_actual_client_secret_here';
}
```
3. **Run the App:**
- The app automatically detects valid credentials
- Real Spotify search will be enabled
- You'll see "🎵 Spotify" instead of "🎮 Demo" in the UI
## How It Works
### 🔄 Automatic Credential Detection
```dart
// The service automatically checks for valid credentials
bool get _hasValidCredentials =>
_clientId != 'YOUR_SPOTIFY_CLIENT_ID' &&
_clientSecret != 'YOUR_SPOTIFY_CLIENT_SECRET';
```
### 🎮 Graceful Fallback
- **Invalid/Missing Credentials** → Demo data
- **Valid Credentials** → Real Spotify API
- **API Errors** → Falls back to demo data
### 🎯 Visual Indicators
- **"🎵 Spotify"** badge = Real API active
- **"🎮 Demo"** badge = Using demo data
- Console logs show configuration status
## Features
### ✅ Working Now (Demo Mode)
- Song search with realistic results
- Upvote/downvote queue management
- Real-time queue reordering
- Album artwork simulation
- Location-based group features
### ✅ Enhanced with Real Spotify
- Actual Spotify track search
- Real album artwork
- Accurate track metadata
- External Spotify links
- Preview URLs (where available)
## Security Notes
⚠️ **Important:** Never commit real credentials to version control!
```bash
# Add this to .gitignore
lib/services/SPOTIFY_SECRET.dart
```
For production apps:
- Use environment variables
- Use secure credential storage
- Implement proper OAuth flows
## File Structure
```
lib/
├── services/
│ ├── spotify_service.dart # Main Spotify API service
│ └── SPOTIFY_SECRET.dart # Your credentials (gitignored)
├── models/
│ └── spotify_track.dart # Spotify data models
└── pages/
├── voting_page.dart # Search & voting interface
├── now_playing_page.dart # Current queue display
└── group_page.dart # Location & group features
```
## API Integration Details
### Client Credentials Flow
- Used for public track search (no user login required)
- Perfect for the collaborative jukebox use case
- Handles token refresh automatically
### Search Functionality
```dart
// Real Spotify search
final tracks = await _spotifyService.searchTracks('summer vibes', limit: 10);
// Automatic fallback to demo data if API unavailable
```
### Error Handling
- Network errors → Demo data
- Invalid credentials → Demo data
- Rate limiting → Demo data
- Token expiration → Automatic refresh
## Challenge Requirements ✅
- ✅ **Music streaming API integration** - Spotify Web API
- ✅ **Track metadata retrieval** - Full track info + artwork
- ✅ **Demo-ready functionality** - Works without setup
- ✅ **Real-world usability** - Graceful fallbacks
## Development Tips
1. **Start with Demo Mode** - Get familiar with the app
2. **Add Real Credentials** - See the enhanced experience
3. **Test Both Modes** - Ensure fallbacks work
4. **Check Console Logs** - See API status messages
Enjoy building your collaborative music experience! 🎶

View file

@ -0,0 +1,68 @@
# Spotify API Setup Instructions
## 🎵 Getting Spotify API Credentials
To enable real Spotify integration in SleepySound, you need to set up a Spotify Developer account and get API credentials.
### Step 1: Create a Spotify Developer Account
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
2. Log in with your Spotify account (create one if needed)
3. Accept the Terms of Service
### Step 2: Create a New App
1. Click "Create an App"
2. Fill in the details:
- **App Name**: SleepySound
- **App Description**: Collaborative music selection for Lido Schenna
- **Redirect URI**: `sleepysound://callback`
3. Check the boxes to agree to terms
4. Click "Create"
### Step 3: Get Your Credentials
1. In your app dashboard, you'll see:
- **Client ID** (public)
- **Client Secret** (keep this private!)
2. Copy these values
### Step 4: Configure the App
1. Open `lib/services/spotify_service.dart`
2. Replace the placeholder values:
```dart
static const String _clientId = 'YOUR_ACTUAL_CLIENT_ID_HERE';
static const String _clientSecret = 'YOUR_ACTUAL_CLIENT_SECRET_HERE';
```
### Step 5: Enable Spotify Features
The app is configured to work with mock data by default. Once you add real credentials:
- Real Spotify search will be enabled
- Track metadata will be fetched from Spotify
- Album artwork will be displayed
- Preview URLs will be available (if provided by Spotify)
## 🚀 Demo Mode
The app works without Spotify credentials using demo data. You can:
- Search for tracks (returns demo results)
- Vote on songs
- See the queue update in real-time
- Experience the full UI/UX
## 🔒 Security Notes
- Never commit your Client Secret to version control
- In production, use environment variables or secure storage
- Consider using Spotify's Authorization Code flow for user-specific features
## 📱 Features Enabled with Spotify API
- ✅ Real track search
- ✅ Album artwork
- ✅ Accurate track duration
- ✅ Artist information
- ✅ Track previews (where available)
- ✅ External Spotify links
## 🎯 Challenge Requirements Met
- ✅ Music streaming API integration (Spotify)
- ✅ Track metadata retrieval
- ✅ Demo-ready functionality
- ✅ Real-world usability
Enjoy building your collaborative music experience! 🎶

Binary file not shown.

View file

@ -8,7 +8,7 @@ plugins {
android {
namespace = "com.example.sleepysound"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11

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}"

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 9.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#121212</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 780 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

View file

@ -1,4 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
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';
import 'services/spam_protection_service.dart';
import 'services/audio_service.dart';
void main() {
runApp(const MyApp());
@ -10,20 +18,42 @@ class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SleepySound',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => MusicQueueService()),
ChangeNotifierProvider(create: (context) => NetworkGroupService()),
ChangeNotifierProvider(create: (context) => SpamProtectionService()),
ChangeNotifierProvider(create: (context) => AudioService()),
],
child: MaterialApp(
title: 'LidoSound',
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6366F1),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF121212),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1E1E1E),
foregroundColor: Colors.white,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: Color(0xFF1E1E1E),
selectedItemColor: Color(0xFF6366F1),
unselectedItemColor: Colors.grey,
type: BottomNavigationBarType.fixed,
),
),
home: const MyHomePage(title: 'Now Playing'),
),
home: const MyHomePage(title: 'Now Playing'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
@ -31,69 +61,53 @@ class MyHomePage extends StatefulWidget {
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
int _selectedIndex = 0;
void _incrementCounter() {
void _onItemTapped(int index) {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
_selectedIndex = index;
});
}
Widget _getSelectedPage() {
switch (_selectedIndex) {
case 0:
return const NowPlayingPage();
case 1:
return const VotingPage();
case 2:
return const GroupPage();
default:
return const NowPlayingPage();
}
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
body: _getSelectedPage(),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.play_circle_filled),
label: 'Now Playing',
),
BottomNavigationBarItem(
icon: Icon(Icons.how_to_vote),
label: 'Voting',
),
BottomNavigationBarItem(
icon: Icon(Icons.group),
label: 'Group',
),
],
currentIndex: _selectedIndex,
backgroundColor: const Color(0xFF1E1E1E),
selectedItemColor: const Color(0xFF6366F1),
unselectedItemColor: Colors.grey,
type: BottomNavigationBarType.fixed,
onTap: _onItemTapped,
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}

View file

@ -0,0 +1,122 @@
import 'package:json_annotation/json_annotation.dart';
part 'spotify_track.g.dart';
@JsonSerializable()
class SpotifyTrack {
final String id;
final String name;
final List<SpotifyArtist> artists;
final SpotifyAlbum album;
@JsonKey(name: 'duration_ms')
final int durationMs;
@JsonKey(name: 'external_urls')
final Map<String, String> externalUrls;
@JsonKey(name: 'preview_url')
final String? previewUrl;
SpotifyTrack({
required this.id,
required this.name,
required this.artists,
required this.album,
required this.durationMs,
required this.externalUrls,
this.previewUrl,
});
factory SpotifyTrack.fromJson(Map<String, dynamic> json) =>
_$SpotifyTrackFromJson(json);
Map<String, dynamic> toJson() => _$SpotifyTrackToJson(this);
String get artistNames => artists.map((artist) => artist.name).join(', ');
String get duration {
final minutes = (durationMs / 60000).floor();
final seconds = ((durationMs % 60000) / 1000).floor();
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
String get imageUrl => album.images.isNotEmpty ? album.images.first.url : '';
}
@JsonSerializable()
class SpotifyArtist {
final String id;
final String name;
SpotifyArtist({
required this.id,
required this.name,
});
factory SpotifyArtist.fromJson(Map<String, dynamic> json) =>
_$SpotifyArtistFromJson(json);
Map<String, dynamic> toJson() => _$SpotifyArtistToJson(this);
}
@JsonSerializable()
class SpotifyAlbum {
final String id;
final String name;
final List<SpotifyImage> images;
SpotifyAlbum({
required this.id,
required this.name,
required this.images,
});
factory SpotifyAlbum.fromJson(Map<String, dynamic> json) =>
_$SpotifyAlbumFromJson(json);
Map<String, dynamic> toJson() => _$SpotifyAlbumToJson(this);
}
@JsonSerializable()
class SpotifyImage {
final int height;
final int width;
final String url;
SpotifyImage({
required this.height,
required this.width,
required this.url,
});
factory SpotifyImage.fromJson(Map<String, dynamic> json) =>
_$SpotifyImageFromJson(json);
Map<String, dynamic> toJson() => _$SpotifyImageToJson(this);
}
@JsonSerializable()
class SpotifySearchResponse {
final SpotifyTracks tracks;
SpotifySearchResponse({required this.tracks});
factory SpotifySearchResponse.fromJson(Map<String, dynamic> json) =>
_$SpotifySearchResponseFromJson(json);
Map<String, dynamic> toJson() => _$SpotifySearchResponseToJson(this);
}
@JsonSerializable()
class SpotifyTracks {
final List<SpotifyTrack> items;
final int total;
SpotifyTracks({
required this.items,
required this.total,
});
factory SpotifyTracks.fromJson(Map<String, dynamic> json) =>
_$SpotifyTracksFromJson(json);
Map<String, dynamic> toJson() => _$SpotifyTracksToJson(this);
}

View file

@ -0,0 +1,88 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'spotify_track.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SpotifyTrack _$SpotifyTrackFromJson(Map<String, dynamic> json) => SpotifyTrack(
id: json['id'] as String,
name: json['name'] as String,
artists:
(json['artists'] as List<dynamic>)
.map((e) => SpotifyArtist.fromJson(e as Map<String, dynamic>))
.toList(),
album: SpotifyAlbum.fromJson(json['album'] as Map<String, dynamic>),
durationMs: (json['duration_ms'] as num).toInt(),
externalUrls: Map<String, String>.from(json['external_urls'] as Map),
previewUrl: json['preview_url'] as String?,
);
Map<String, dynamic> _$SpotifyTrackToJson(SpotifyTrack instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'artists': instance.artists,
'album': instance.album,
'duration_ms': instance.durationMs,
'external_urls': instance.externalUrls,
'preview_url': instance.previewUrl,
};
SpotifyArtist _$SpotifyArtistFromJson(Map<String, dynamic> json) =>
SpotifyArtist(id: json['id'] as String, name: json['name'] as String);
Map<String, dynamic> _$SpotifyArtistToJson(SpotifyArtist instance) =>
<String, dynamic>{'id': instance.id, 'name': instance.name};
SpotifyAlbum _$SpotifyAlbumFromJson(Map<String, dynamic> json) => SpotifyAlbum(
id: json['id'] as String,
name: json['name'] as String,
images:
(json['images'] as List<dynamic>)
.map((e) => SpotifyImage.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SpotifyAlbumToJson(SpotifyAlbum instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'images': instance.images,
};
SpotifyImage _$SpotifyImageFromJson(Map<String, dynamic> json) => SpotifyImage(
height: (json['height'] as num).toInt(),
width: (json['width'] as num).toInt(),
url: json['url'] as String,
);
Map<String, dynamic> _$SpotifyImageToJson(SpotifyImage instance) =>
<String, dynamic>{
'height': instance.height,
'width': instance.width,
'url': instance.url,
};
SpotifySearchResponse _$SpotifySearchResponseFromJson(
Map<String, dynamic> json,
) => SpotifySearchResponse(
tracks: SpotifyTracks.fromJson(json['tracks'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SpotifySearchResponseToJson(
SpotifySearchResponse instance,
) => <String, dynamic>{'tracks': instance.tracks};
SpotifyTracks _$SpotifyTracksFromJson(Map<String, dynamic> json) =>
SpotifyTracks(
items:
(json['items'] as List<dynamic>)
.map((e) => SpotifyTrack.fromJson(e as Map<String, dynamic>))
.toList(),
total: (json['total'] as num).toInt(),
);
Map<String, dynamic> _$SpotifyTracksToJson(SpotifyTracks instance) =>
<String, dynamic>{'items': instance.items, 'total': instance.total};

View file

@ -0,0 +1,517 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/network_group_service.dart';
class GroupPage extends StatefulWidget {
const GroupPage({super.key});
@override
State<GroupPage> createState() => _GroupPageState();
}
class _GroupPageState extends State<GroupPage> {
@override
Widget build(BuildContext context) {
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,
),
),
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
? '<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: 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',
),
),
],
],
),
),
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(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentUser.name,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'Active since ${_formatDuration(DateTime.now().difference(currentUser.joinedAt))} ago',
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
],
),
),
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),
// 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(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 48,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Searching for other users...',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
SizedBox(height: 8),
Text(
'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: [
if (user.isListening && user.currentTrackName != null) ...[
const Icon(
Icons.music_note,
color: Color(0xFF6366F1),
size: 14,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'Listening to "${user.currentTrackName}" by ${user.currentArtist}',
style: const TextStyle(
color: Color(0xFF6366F1),
fontSize: 12,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
] else ...[
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',
),
),
],
],
),
),
Column(
children: [
if (user.isListening && !isCurrentUser) ...[
// Join Listening Session Button
IconButton(
onPressed: () async {
final success = await networkService.joinListeningSession(user);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? 'Joined ${user.name}\'s listening session! 🎵'
: 'Failed to join listening session',
),
backgroundColor: success
? const Color(0xFF22C55E)
: const Color(0xFFEF4444),
duration: const Duration(seconds: 3),
),
);
}
},
icon: const Icon(
Icons.headphones,
color: Color(0xFF6366F1),
size: 20,
),
tooltip: 'Join listening session',
),
const SizedBox(height: 4),
],
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,
),
),
],
),
),
),
// 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),
),
),
),
),
],
),
],
),
),
);
},
);
}
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,34 @@
import 'package:flutter/material.dart';
class VotingPage extends StatelessWidget {
const VotingPage({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFF121212),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.how_to_vote, size: 100, color: Color(0xFF6366F1)),
SizedBox(height: 20),
Text(
'Voting',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(height: 10),
Text(
'Vote for the next song',
style: TextStyle(color: Colors.grey),
),
],
),
),
);
}
}

View file

@ -0,0 +1,317 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/music_queue_service.dart';
import '../models/spotify_track.dart';
class NowPlayingPage extends StatelessWidget {
const NowPlayingPage({super.key});
@override
Widget build(BuildContext context) {
return Consumer<MusicQueueService>(
builder: (context, queueService, child) {
final currentTrack = queueService.currentTrack;
final queue = queueService.queue;
return Scaffold(
backgroundColor: const Color(0xFF121212),
body: SafeArea(
child: SingleChildScrollView(
child: Column(
children: [
// Now Playing Header
Container(
padding: const EdgeInsets.all(20),
child: const Text(
'Now Playing',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
// Current Track Display
Container(
height: MediaQuery.of(context).size.height * 0.5,
margin: const EdgeInsets.all(20),
child: currentTrack != null
? _buildCurrentTrackCard(context, currentTrack, queueService)
: _buildNoTrackCard(),
),
// Playback Controls
Container(
padding: const EdgeInsets.all(20),
child: _buildPlaybackControls(queueService),
),
// Queue Preview
Container(
height: MediaQuery.of(context).size.height * 0.3,
margin: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 10),
child: Text(
'Up Next',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
Expanded(
child: queue.isEmpty
? const Center(
child: Text(
'No songs in queue\nGo to Voting to add some!',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
)
: ListView.builder(
itemCount: queue.length,
itemBuilder: (context, index) {
return _buildQueueItem(queue[index], index + 1);
},
),
),
],
),
),
const SizedBox(height: 20), // Extra padding at bottom
],
),
),
),
);
},
);
}
Widget _buildCurrentTrackCard(BuildContext context, SpotifyTrack currentTrack, MusicQueueService queueService) {
return Card(
color: const Color(0xFF1E1E1E),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Album Art
Container(
width: 160,
height: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: const Color(0xFF2A2A2A),
),
child: currentTrack.album.images.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
currentTrack.album.images.first.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
size: 80,
color: Colors.grey,
);
},
),
)
: const Icon(
Icons.music_note,
size: 80,
color: Colors.grey,
),
),
const SizedBox(height: 15),
// Track Info
Text(
currentTrack.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
currentTrack.artists.map((a) => a.name).join(', '),
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
currentTrack.album.name,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Progress Bar
const SizedBox(height: 15),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
LinearProgressIndicator(
value: queueService.progress,
backgroundColor: Colors.grey[800],
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF6366F1)),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration((queueService.progress * currentTrack.durationMs / 1000).round()),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
Text(
currentTrack.duration,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildNoTrackCard() {
return Card(
color: const Color(0xFF1E1E1E),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_off,
size: 80,
color: Colors.grey,
),
SizedBox(height: 20),
Text(
'No track playing',
style: TextStyle(
fontSize: 20,
color: Colors.grey,
),
),
SizedBox(height: 10),
Text(
'Add some songs from the Voting tab!',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
),
);
}
Widget _buildPlaybackControls(MusicQueueService queueService) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Previous (disabled for now)
IconButton(
onPressed: null,
icon: const Icon(Icons.skip_previous),
iconSize: 40,
color: Colors.grey,
),
// Play/Pause
IconButton(
onPressed: queueService.togglePlayPause,
icon: Icon(queueService.isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled),
iconSize: 60,
color: const Color(0xFF6366F1),
),
// Next
IconButton(
onPressed: queueService.queue.isNotEmpty ? queueService.skipTrack : null,
icon: const Icon(Icons.skip_next),
iconSize: 40,
color: queueService.queue.isNotEmpty ? Colors.white : Colors.grey,
),
],
);
}
Widget _buildQueueItem(QueueItem item, int position) {
return Card(
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF6366F1),
child: Text(
'$position',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
title: Text(
item.track.name,
style: const TextStyle(color: Colors.white, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
item.track.artists.map((a) => a.name).join(', '),
style: const TextStyle(color: Colors.grey, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.thumb_up, color: Colors.green, size: 16),
const SizedBox(width: 4),
Text(
'${item.votes}',
style: const TextStyle(color: Colors.green, fontSize: 12),
),
],
),
),
);
}
String _formatDuration(int seconds) {
int minutes = seconds ~/ 60;
int remainingSeconds = seconds % 60;
return '${minutes}:${remainingSeconds.toString().padLeft(2, '0')}';
}
}

View file

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class GroupPage extends StatelessWidget {
const GroupPage({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFF121212),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.group, size: 100, color: Color(0xFF6366F1)),
SizedBox(height: 20),
Text(
'Group',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(height: 10),
Text(
'Manage your listening group',
style: TextStyle(color: Colors.grey),
),
],
),
),
);
}
}

View file

@ -0,0 +1,793 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/music_queue_service.dart';
import '../services/spam_protection_service.dart';
import '../services/genre_filter_service.dart';
import '../models/spotify_track.dart';
import '../widgets/user_activity_status.dart';
class VotingPage extends StatefulWidget {
const VotingPage({super.key});
@override
State<VotingPage> createState() => _VotingPageState();
}
class _VotingPageState extends State<VotingPage> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
List<SpotifyTrack> _searchResults = [];
bool _isLoading = false;
String _statusMessage = '';
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
@override
void initState() {
super.initState();
_loadInitialQueue();
_searchFocusNode.addListener(_onSearchFocusChange);
}
@override
void dispose() {
_hideSearchOverlay();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
void _onSearchFocusChange() {
if (_searchFocusNode.hasFocus && _searchResults.isNotEmpty) {
_showSearchOverlay();
} else if (!_searchFocusNode.hasFocus) {
// Delay hiding to allow for taps on results
Future.delayed(const Duration(milliseconds: 150), () {
_hideSearchOverlay();
});
}
}
void _showSearchOverlay() {
if (_overlayEntry != null) return;
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
}
void _hideSearchOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
var offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => Positioned(
left: offset.dx + 20,
top: offset.dy + 200, // Adjust based on search field position
width: size.width - 40,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: const Color(0xFF1E1E1E),
child: Container(
constraints: const BoxConstraints(maxHeight: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF6366F1).withOpacity(0.3)),
),
child: _buildSearchResultsOverlay(),
),
),
),
),
);
}
Widget _buildSearchResultsOverlay() {
if (_searchResults.isEmpty) {
return Container(
padding: const EdgeInsets.all(20),
child: const Text(
'No results found',
style: TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
),
);
}
return ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final track = _searchResults[index];
return _buildSearchResultItem(track, index);
},
);
}
Widget _buildSearchResultItem(SpotifyTrack track, int index) {
return InkWell(
onTap: () {
_addToQueue(track);
_hideSearchOverlay();
_searchController.clear();
_searchFocusNode.unfocus();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Album Art
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: const Color(0xFF2A2A2A),
),
child: track.album.images.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
track.album.images.first.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
color: Colors.grey,
size: 16,
);
},
),
)
: const Icon(
Icons.music_note,
color: Colors.grey,
size: 16,
),
),
const SizedBox(width: 12),
// Track Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
track.artists.map((a) => a.name).join(', '),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Add Icon
const Icon(
Icons.add_circle_outline,
color: Color(0xFF6366F1),
size: 20,
),
],
),
),
);
}
Future<void> _loadInitialQueue() async {
final queueService = Provider.of<MusicQueueService>(context, listen: false);
await queueService.initializeQueue();
}
Future<void> _searchSpotify(String query) async {
if (query.isEmpty) {
setState(() {
_searchResults = [];
_statusMessage = '';
});
_hideSearchOverlay();
return;
}
// Check if search query is appropriate
if (!GenreFilterService.isSearchQueryAppropriate(query)) {
final suggestions = GenreFilterService.getAlternativeSearchSuggestions(query);
setState(() {
_isLoading = false;
_statusMessage = 'Search term not suitable for the peaceful Lido atmosphere. Try: ${suggestions.join(', ')}';
_searchResults = [];
});
_hideSearchOverlay();
return;
}
final spamService = Provider.of<SpamProtectionService>(context, listen: false);
final userId = spamService.getCurrentUserId();
// Check spam protection for suggestions
if (!spamService.canSuggest(userId)) {
final cooldown = spamService.getSuggestionCooldownRemaining(userId);
final blockMessage = spamService.getBlockMessage(userId);
setState(() {
_isLoading = false;
_statusMessage = blockMessage ?? 'Please wait $cooldown seconds before searching again.';
_searchResults = [];
});
_hideSearchOverlay();
return;
}
setState(() {
_isLoading = true;
_statusMessage = 'Searching for "$query"...';
});
try {
final queueService = Provider.of<MusicQueueService>(context, listen: false);
final results = await queueService.searchTracks(query);
// No filtering on search results - let users see all tracks
// Filtering only happens when adding to queue to maintain atmosphere
// Record the suggestion attempt
spamService.recordSuggestion(userId);
setState(() {
_searchResults = results;
_isLoading = false;
if (results.isEmpty) {
_statusMessage = 'No tracks found for "$query"';
} else {
_statusMessage = 'Found ${results.length} tracks';
}
});
// Show overlay if we have results and search field is focused
if (results.isNotEmpty && _searchFocusNode.hasFocus) {
_showSearchOverlay();
} else {
_hideSearchOverlay();
}
} catch (e) {
setState(() {
_isLoading = false;
_statusMessage = 'Search failed: ${e.toString()}';
_searchResults = [];
});
_hideSearchOverlay();
}
}
void _addToQueue(SpotifyTrack track) {
final spamService = Provider.of<SpamProtectionService>(context, listen: false);
final userId = spamService.getCurrentUserId();
// Check if user can suggest (add to queue)
if (!spamService.canSuggest(userId)) {
final cooldown = spamService.getSuggestionCooldownRemaining(userId);
final blockMessage = spamService.getBlockMessage(userId);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(blockMessage ?? 'Please wait $cooldown seconds before adding another song.'),
duration: const Duration(seconds: 3),
backgroundColor: Colors.orange,
),
);
return;
}
// Check if track is appropriate for atmosphere
final rejectionReason = GenreFilterService.getRejectionReason(track);
if (rejectionReason != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(rejectionReason),
duration: const Duration(seconds: 4),
backgroundColor: Colors.orange,
),
);
return;
}
final queueService = Provider.of<MusicQueueService>(context, listen: false);
queueService.addToQueue(track);
// Record the suggestion
spamService.recordSuggestion(userId);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added "${track.name}" to queue'),
duration: const Duration(seconds: 2),
backgroundColor: const Color(0xFF6366F1),
),
);
}
@override
Widget build(BuildContext context) {
return Consumer<MusicQueueService>(
builder: (context, queueService, child) {
return Scaffold(
backgroundColor: const Color(0xFF121212),
body: SafeArea(
child: Column(
children: [
// User Activity Status
Consumer<SpamProtectionService>(
builder: (context, spamService, child) {
return UserActivityStatus(spamService: spamService);
},
),
// Header with Search
Container(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Voting',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
// Status indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF6366F1),
width: 1,
),
),
child: const Text(
'🎵 Spotify',
style: TextStyle(
color: Color(0xFF6366F1),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 20),
// Search Bar
CompositedTransformTarget(
link: _layerLink,
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Search for songs, artists, albums...',
hintStyle: const TextStyle(color: Colors.grey),
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6366F1)),
),
),
),
if (_searchController.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear, color: Colors.grey),
onPressed: () {
_searchController.clear();
_hideSearchOverlay();
setState(() {
_searchResults = [];
_statusMessage = '';
});
},
),
],
),
filled: true,
fillColor: const Color(0xFF1E1E1E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF6366F1), width: 2),
),
),
onChanged: (value) {
// Search as user types (with debounce)
if (value.length >= 3) {
Future.delayed(const Duration(milliseconds: 500), () {
if (_searchController.text == value) {
_searchSpotify(value);
}
});
} else if (value.isEmpty) {
setState(() {
_searchResults = [];
_statusMessage = '';
});
_hideSearchOverlay();
}
},
onSubmitted: _searchSpotify,
),
),
// Atmosphere Info
Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF6366F1).withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.spa, color: Color(0xFF6366F1), size: 20),
SizedBox(width: 8),
Text(
'Lido Atmosphere',
style: TextStyle(
color: Color(0xFF6366F1),
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 8),
Text(
GenreFilterService.getAtmosphereDescription(),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
const SizedBox(height: 12),
const Text(
'Try these searches:',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: GenreFilterService.getSuggestedSearchTerms()
.take(8)
.map((term) => InkWell(
onTap: () {
_searchController.text = term;
_searchSpotify(term);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
term,
style: const TextStyle(
color: Color(0xFF6366F1),
fontSize: 11,
),
),
),
))
.toList(),
),
],
),
),
// Status Message
if (_statusMessage.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_statusMessage,
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
),
],
),
),
// Queue Section
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Music Queue',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF6366F1),
width: 1,
),
),
child: Text(
'${queueService.queue.length} songs',
style: const TextStyle(
color: Color(0xFF6366F1),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
const SizedBox(height: 16),
Expanded(
child: _buildQueueView(queueService),
),
],
),
),
],
),
),
);
},
);
}
Widget _buildQueueView(MusicQueueService queueService) {
final queue = queueService.queue;
if (queue.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue_music,
size: 80,
color: Colors.grey,
),
SizedBox(height: 20),
Text(
'Queue is empty',
style: TextStyle(
color: Colors.grey,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 10),
Text(
'Search and add songs to get started!',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(20),
itemCount: queue.length,
itemBuilder: (context, index) {
final queueItem = queue[index];
return _buildQueueItemCard(queueItem, index, queueService);
},
);
}
Widget _buildQueueItemCard(QueueItem queueItem, int index, MusicQueueService queueService) {
return Card(
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Position
CircleAvatar(
backgroundColor: const Color(0xFF6366F1),
radius: 16,
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
const SizedBox(width: 12),
// Album Art
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: const Color(0xFF2A2A2A),
),
child: queueItem.track.album.images.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
queueItem.track.album.images.first.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
color: Colors.grey,
size: 20,
);
},
),
)
: const Icon(
Icons.music_note,
color: Colors.grey,
size: 20,
),
),
const SizedBox(width: 12),
// Track Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
queueItem.track.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
queueItem.track.artists.map((a) => a.name).join(', '),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Voting Buttons
Column(
children: [
Consumer<SpamProtectionService>(
builder: (context, spamService, child) {
final userId = spamService.getCurrentUserId();
final canVote = spamService.canVote(userId);
final cooldown = spamService.getVoteCooldownRemaining(userId);
return IconButton(
onPressed: canVote ? () {
queueService.upvote(index);
spamService.recordVote(userId);
} : null,
icon: Icon(
Icons.keyboard_arrow_up,
color: canVote ? Colors.green : Colors.grey,
size: 28,
),
tooltip: canVote ? 'Upvote' : 'Wait $cooldown seconds',
);
}
),
Text(
'${queueItem.votes}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Consumer<SpamProtectionService>(
builder: (context, spamService, child) {
final userId = spamService.getCurrentUserId();
final canVote = spamService.canVote(userId);
final cooldown = spamService.getVoteCooldownRemaining(userId);
return IconButton(
onPressed: canVote ? () {
queueService.downvote(index);
spamService.recordVote(userId);
} : null,
icon: Icon(
Icons.keyboard_arrow_down,
color: canVote ? Colors.red : Colors.grey,
size: 28,
),
tooltip: canVote ? 'Downvote' : 'Wait $cooldown seconds',
);
}
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,491 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/music_queue_service.dart';
import '../models/spotify_track.dart';
class VotingPage extends StatefulWidget {
const VotingPage({super.key});
@override
State<VotingPage> createState() => _VotingPageState();
}
class _VotingPageState extends State<VotingPage> {
final TextEditingController _searchController = TextEditingController();
List<SpotifyTrack> _searchResults = [];
bool _isLoading = false;
String _statusMessage = '';
@override
void initState() {
super.initState();
_loadInitialQueue();
}
Future<void> _loadInitialQueue() async {
final queueService = Provider.of<MusicQueueService>(context, listen: false);
await queueService.initializeQueue();
}
Future<void> _searchSpotify(String query) async {
if (query.isEmpty) return;
setState(() {
_isLoading = true;
_statusMessage = 'Searching for "$query"...';
});
try {
final queueService = Provider.of<MusicQueueService>(context, listen: false);
final results = await queueService.searchTracks(query);
setState(() {
_searchResults = results;
_isLoading = false;
_statusMessage = results.isEmpty
? 'No tracks found for "$query"'
: 'Found ${results.length} tracks';
});
} catch (e) {
setState(() {
_isLoading = false;
_statusMessage = 'Search failed: ${e.toString()}';
_searchResults = [];
});
}
}
void _addToQueue(SpotifyTrack track) {
final queueService = Provider.of<MusicQueueService>(context, listen: false);
queueService.addToQueue(track);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added "${track.name}" to queue'),
duration: const Duration(seconds: 2),
backgroundColor: const Color(0xFF6366F1),
),
);
}
@override
Widget build(BuildContext context) {
return Consumer<MusicQueueService>(
builder: (context, queueService, child) {
return Scaffold(
backgroundColor: const Color(0xFF121212),
body: SafeArea(
child: Column(
children: [
// Header with Search
Container(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Voting',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
// Status indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF6366F1),
width: 1,
),
),
child: const Text(
'🎵 Spotify',
style: TextStyle(
color: Color(0xFF6366F1),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 20),
// Search Bar
TextField(
controller: _searchController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Search for songs, artists, albums...',
hintStyle: const TextStyle(color: Colors.grey),
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _isLoading
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6366F1)),
),
),
)
: null,
filled: true,
fillColor: const Color(0xFF1E1E1E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onSubmitted: _searchSpotify,
),
// Status Message
if (_statusMessage.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_statusMessage,
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
),
],
),
),
// Search Results and Queue
Expanded(
child: DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
labelColor: Color(0xFF6366F1),
unselectedLabelColor: Colors.grey,
indicatorColor: Color(0xFF6366F1),
tabs: [
Tab(text: 'Search Results'),
Tab(text: 'Queue'),
],
),
Expanded(
child: TabBarView(
children: [
// Search Results Tab
_buildSearchResults(),
// Queue Tab
_buildQueueView(queueService),
],
),
),
],
),
),
),
],
),
),
);
},
);
}
Widget _buildSearchResults() {
if (_searchResults.isEmpty && !_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 80,
color: Colors.grey,
),
SizedBox(height: 20),
Text(
'Search for songs to add to the queue',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(20),
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final track = _searchResults[index];
return _buildTrackCard(track);
},
);
}
Widget _buildQueueView(MusicQueueService queueService) {
final queue = queueService.queue;
if (queue.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue_music,
size: 80,
color: Colors.grey,
),
SizedBox(height: 20),
Text(
'Queue is empty',
style: TextStyle(
color: Colors.grey,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 10),
Text(
'Search and add songs to get started!',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(20),
itemCount: queue.length,
itemBuilder: (context, index) {
final queueItem = queue[index];
return _buildQueueItemCard(queueItem, index, queueService);
},
);
}
Widget _buildTrackCard(SpotifyTrack track) {
return Card(
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Album Art
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: const Color(0xFF2A2A2A),
),
child: track.album.images.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
track.album.images.first.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
color: Colors.grey,
);
},
),
)
: const Icon(
Icons.music_note,
color: Colors.grey,
),
),
const SizedBox(width: 12),
// Track Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
track.artists.map((a) => a.name).join(', '),
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
track.album.name,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Add Button
IconButton(
onPressed: () => _addToQueue(track),
icon: const Icon(
Icons.add_circle,
color: Color(0xFF6366F1),
size: 32,
),
),
],
),
),
);
}
Widget _buildQueueItemCard(QueueItem queueItem, int index, MusicQueueService queueService) {
return Card(
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Position
CircleAvatar(
backgroundColor: const Color(0xFF6366F1),
radius: 16,
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
const SizedBox(width: 12),
// Album Art
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: const Color(0xFF2A2A2A),
),
child: queueItem.track.album.images.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
queueItem.track.album.images.first.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
color: Colors.grey,
size: 20,
);
},
),
)
: const Icon(
Icons.music_note,
color: Colors.grey,
size: 20,
),
),
const SizedBox(width: 12),
// Track Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
queueItem.track.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
queueItem.track.artists.map((a) => a.name).join(', '),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Voting Buttons
Column(
children: [
IconButton(
onPressed: () => queueService.upvote(index),
icon: const Icon(
Icons.keyboard_arrow_up,
color: Colors.green,
size: 28,
),
),
Text(
'${queueItem.votes}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: () => queueService.downvote(index),
icon: const Icon(
Icons.keyboard_arrow_down,
color: Colors.red,
size: 28,
),
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,242 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import '../models/spotify_track.dart';
class AudioService extends ChangeNotifier {
static final AudioService _instance = AudioService._internal();
factory AudioService() => _instance;
AudioService._internal() {
_initializePlayer();
}
final AudioPlayer _audioPlayer = AudioPlayer();
// Current track state
SpotifyTrack? _currentTrack;
bool _isPlaying = false;
bool _isLoading = false;
Duration _currentPosition = Duration.zero;
Duration _totalDuration = Duration.zero;
// Getters
SpotifyTrack? get currentTrack => _currentTrack;
bool get isPlaying => _isPlaying;
bool get isLoading => _isLoading;
Duration get currentPosition => _currentPosition;
Duration get totalDuration => _totalDuration;
double get progress => _totalDuration.inMilliseconds > 0
? _currentPosition.inMilliseconds / _totalDuration.inMilliseconds
: 0.0;
// Free audio sources for demo purposes
// Using royalty-free music from reliable sources
final Map<String, String> _demoAudioUrls = {
// Peaceful, lido-appropriate tracks
'pop1': 'https://www.bensound.com/bensound-music/bensound-relaxing.mp3',
'pop2': 'https://www.bensound.com/bensound-music/bensound-sunny.mp3',
'pop3': 'https://www.bensound.com/bensound-music/bensound-jazzcomedy.mp3',
'pop4': 'https://www.bensound.com/bensound-music/bensound-acousticbreeze.mp3',
'1': 'https://www.bensound.com/bensound-music/bensound-creativeminds.mp3',
'2': 'https://www.bensound.com/bensound-music/bensound-happyrock.mp3',
'3': 'https://www.bensound.com/bensound-music/bensound-ukulele.mp3',
'4': 'https://www.bensound.com/bensound-music/bensound-summer.mp3',
'5': 'https://www.bensound.com/bensound-music/bensound-happiness.mp3',
};
void _initializePlayer() {
// Listen to player state changes
_audioPlayer.onPlayerStateChanged.listen((PlayerState state) {
_isPlaying = state == PlayerState.playing;
_isLoading = state == PlayerState.stopped && _currentTrack != null;
notifyListeners();
});
// Listen to position changes
_audioPlayer.onPositionChanged.listen((Duration position) {
_currentPosition = position;
notifyListeners();
});
// Listen to duration changes
_audioPlayer.onDurationChanged.listen((Duration duration) {
_totalDuration = duration;
notifyListeners();
});
// Listen for track completion
_audioPlayer.onPlayerComplete.listen((_) {
_onTrackComplete();
});
}
Future<void> playTrack(SpotifyTrack track) async {
try {
_isLoading = true;
_currentTrack = track;
notifyListeners();
// Check if we have a demo URL for this track
String? audioUrl = _demoAudioUrls[track.id];
if (audioUrl != null) {
// Play the demo audio
await _audioPlayer.play(UrlSource(audioUrl));
print('Playing audio for: ${track.name} by ${track.artistNames}');
} else {
// For tracks without demo URLs, simulate playback
print('Simulating playback for: ${track.name} by ${track.artistNames}');
_simulateTrackPlayback(track);
}
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error playing track: $e');
_isLoading = false;
// Fallback to simulation
_simulateTrackPlayback(track);
notifyListeners();
}
}
void _simulateTrackPlayback(SpotifyTrack track) {
// Set simulated duration
_totalDuration = Duration(milliseconds: track.durationMs);
_currentPosition = Duration.zero;
_isPlaying = true;
// Simulate playback progress
_startSimulatedProgress();
}
void _startSimulatedProgress() {
if (_isPlaying && _currentTrack != null) {
Future.delayed(const Duration(seconds: 1), () {
if (_isPlaying && _currentTrack != null) {
_currentPosition = _currentPosition + const Duration(seconds: 1);
if (_currentPosition >= _totalDuration) {
_onTrackComplete();
} else {
notifyListeners();
_startSimulatedProgress();
}
}
});
}
}
Future<void> togglePlayPause() async {
try {
if (_isPlaying) {
await _audioPlayer.pause();
} else {
if (_currentTrack != null) {
// Check if we have a real audio URL
String? audioUrl = _demoAudioUrls[_currentTrack!.id];
if (audioUrl != null) {
await _audioPlayer.resume();
} else {
// Resume simulation
_isPlaying = true;
_startSimulatedProgress();
}
}
}
notifyListeners();
} catch (e) {
print('Error toggling play/pause: $e');
// Fallback to simulation toggle
_isPlaying = !_isPlaying;
if (_isPlaying) {
_startSimulatedProgress();
}
notifyListeners();
}
}
Future<void> stop() async {
try {
await _audioPlayer.stop();
} catch (e) {
print('Error stopping audio: $e');
}
_isPlaying = false;
_currentPosition = Duration.zero;
_currentTrack = null;
notifyListeners();
}
Future<void> seekTo(Duration position) async {
try {
// Check if we have a real audio URL
if (_currentTrack != null && _demoAudioUrls.containsKey(_currentTrack!.id)) {
await _audioPlayer.seek(position);
} else {
// Simulate seeking
_currentPosition = position;
notifyListeners();
}
} catch (e) {
print('Error seeking: $e');
// Fallback to simulation
_currentPosition = position;
notifyListeners();
}
}
void _onTrackComplete() {
_isPlaying = false;
_currentPosition = Duration.zero;
notifyListeners();
// Notify that track is complete (for queue management)
onTrackComplete?.call();
}
// Callback for when a track completes
Function()? onTrackComplete;
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
// Get formatted time strings
String get currentPositionString => _formatDuration(_currentPosition);
String get totalDurationString => _formatDuration(_totalDuration);
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
return '$twoDigitMinutes:$twoDigitSeconds';
}
// Add better demo audio URLs (using royalty-free sources)
void addDemoAudioUrl(String trackId, String audioUrl) {
_demoAudioUrls[trackId] = audioUrl;
}
// Add local asset support
Future<void> playAsset(SpotifyTrack track, String assetPath) async {
try {
_isLoading = true;
_currentTrack = track;
notifyListeners();
await _audioPlayer.play(AssetSource(assetPath));
print('Playing asset: $assetPath for ${track.name}');
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error playing asset: $e');
_isLoading = false;
_simulateTrackPlayback(track);
notifyListeners();
}
}
}

View file

@ -0,0 +1,288 @@
import 'package:flutter/foundation.dart';
import '../models/spotify_track.dart';
class GenreFilterService {
// Allowed genres for the Lido atmosphere (chill, ambient, relaxing)
static const List<String> allowedGenres = [
// Chill and Ambient
'chill',
'chillout',
'ambient',
'new age',
'meditation',
'nature sounds',
'spa',
'yoga',
// Smooth genres
'smooth jazz',
'neo soul',
'downtempo',
'trip hop',
'lo-fi',
'lo-fi hip hop',
'chillwave',
'synthwave',
// Acoustic and Folk
'acoustic',
'folk',
'indie folk',
'singer-songwriter',
'soft rock',
'alternative',
'indie',
// World and Cultural
'world music',
'bossa nova',
'latin',
'reggae',
'dub',
'tropical',
'caribbean',
// Electronic (chill variants)
'house',
'deep house',
'minimal techno',
'ambient techno',
'electronica',
'minimal',
// Classical and Instrumental
'classical',
'instrumental',
'piano',
'string quartet',
'chamber music',
'contemporary classical',
];
// Explicitly blocked genres (too energetic/aggressive for Lido)
static const List<String> blockedGenres = [
'metal',
'death metal',
'black metal',
'hardcore',
'punk',
'hardcore punk',
'grindcore',
'screamo',
'dubstep',
'drum and bass',
'breakcore',
'speedcore',
'gabber',
'hardstyle',
'hard trance',
'psytrance',
'hard rock',
'thrash',
'noise',
'industrial',
'aggressive',
'rap',
'hip hop',
'trap',
'drill',
'grime',
'gangsta rap',
];
// Keywords that suggest inappropriate content
static const List<String> blockedKeywords = [
'explicit',
'party',
'club',
'rave',
'aggressive',
'angry',
'violent',
'loud',
'hardcore',
'extreme',
'intense',
'heavy',
'wild',
'crazy',
'insane',
'brutal',
'savage',
'beast',
'fire',
'lit',
'banger',
'drop',
'bass drop',
'festival',
'mosh',
'headbang',
];
// Check if a track is appropriate for the Lido atmosphere
static bool isTrackAllowed(SpotifyTrack track) {
final trackName = track.name.toLowerCase();
final artistNames = track.artists.map((a) => a.name.toLowerCase()).join(' ');
final albumName = track.album.name.toLowerCase();
// Check for blocked keywords in track, artist, or album names
for (final keyword in blockedKeywords) {
if (trackName.contains(keyword) ||
artistNames.contains(keyword) ||
albumName.contains(keyword)) {
if (kDebugMode) {
print('Track blocked due to keyword: $keyword');
}
return false;
}
}
// For now, we'll allow tracks unless they contain blocked keywords
// In a real implementation, you'd check against Spotify's genre data
return true;
}
// Check if a genre is allowed
static bool isGenreAllowed(String genre) {
final lowerGenre = genre.toLowerCase();
// Check if explicitly blocked
if (blockedGenres.contains(lowerGenre)) {
return false;
}
// Check if explicitly allowed
if (allowedGenres.contains(lowerGenre)) {
return true;
}
// Check for partial matches in allowed genres
for (final allowedGenre in allowedGenres) {
if (lowerGenre.contains(allowedGenre) || allowedGenre.contains(lowerGenre)) {
return true;
}
}
// Check for partial matches in blocked genres
for (final blockedGenre in blockedGenres) {
if (lowerGenre.contains(blockedGenre) || blockedGenre.contains(lowerGenre)) {
return false;
}
}
// Default to allowed if not explicitly blocked
return true;
}
// Get suggested search terms for the atmosphere
static List<String> getSuggestedSearchTerms() {
return [
'chill',
'ambient',
'acoustic',
'coffee shop',
'study music',
'relaxing',
'peaceful',
'smooth',
'sunset',
'ocean',
'nature',
'meditation',
'spa music',
'lo-fi',
'bossa nova',
'jazz',
'instrumental',
'piano',
'guitar',
'folk',
'indie',
'world music',
'downtempo',
'chillout',
'lounge',
'soft rock',
];
}
// Get genre description for users
static String getAtmosphereDescription() {
return 'To maintain the peaceful Lido atmosphere, we feature chill, ambient, and relaxing music. Think coffee shop vibes, sunset sounds, and music that enhances tranquility.';
}
// Filter search results based on allowed genres
static List<SpotifyTrack> filterSearchResults(List<SpotifyTrack> tracks) {
return tracks.where((track) => isTrackAllowed(track)).toList();
}
// Get reason why a track might be rejected
static String? getRejectionReason(SpotifyTrack track) {
final trackName = track.name.toLowerCase();
final artistNames = track.artists.map((a) => a.name.toLowerCase()).join(' ');
final albumName = track.album.name.toLowerCase();
// Check for blocked keywords
for (final keyword in blockedKeywords) {
if (trackName.contains(keyword) ||
artistNames.contains(keyword) ||
albumName.contains(keyword)) {
return 'This track contains content that might disturb the peaceful Lido atmosphere. Try searching for more chill or ambient music.';
}
}
return null;
}
// Check if search query suggests inappropriate content
static bool isSearchQueryAppropriate(String query) {
final lowerQuery = query.toLowerCase();
for (final keyword in blockedKeywords) {
if (lowerQuery.contains(keyword)) {
return false;
}
}
for (final genre in blockedGenres) {
if (lowerQuery.contains(genre)) {
return false;
}
}
return true;
}
// Get alternative search suggestions for inappropriate queries
static List<String> getAlternativeSearchSuggestions(String inappropriateQuery) {
// Map inappropriate terms to chill alternatives
final alternatives = {
'party': ['chill', 'lounge', 'relaxing'],
'club': ['ambient', 'downtempo', 'smooth'],
'rave': ['meditation', 'spa music', 'nature sounds'],
'metal': ['acoustic', 'folk', 'classical'],
'punk': ['indie', 'alternative', 'soft rock'],
'hardcore': ['peaceful', 'calming', 'serene'],
'aggressive': ['gentle', 'soothing', 'mellow'],
'loud': ['quiet', 'soft', 'whisper'],
'heavy': ['light', 'airy', 'floating'],
'intense': ['relaxed', 'easy', 'laid-back'],
};
final suggestions = <String>[];
final lowerQuery = inappropriateQuery.toLowerCase();
for (final entry in alternatives.entries) {
if (lowerQuery.contains(entry.key)) {
suggestions.addAll(entry.value);
}
}
if (suggestions.isEmpty) {
suggestions.addAll(['chill', 'ambient', 'relaxing', 'peaceful']);
}
return suggestions.take(3).toList();
}
}

View file

@ -0,0 +1,200 @@
import 'package:flutter/foundation.dart';
import '../models/spotify_track.dart';
import '../services/spotify_service.dart';
import '../services/audio_service.dart';
class QueueItem {
final SpotifyTrack track;
int votes;
bool userVoted;
final DateTime addedAt;
QueueItem({
required this.track,
this.votes = 1,
this.userVoted = true,
DateTime? addedAt,
}) : addedAt = addedAt ?? DateTime.now();
Map<String, dynamic> toJson() => {
'id': track.id,
'title': track.name,
'artist': track.artistNames,
'votes': votes,
'userVoted': userVoted,
'duration': track.duration,
'imageUrl': track.imageUrl,
'addedAt': addedAt.toIso8601String(),
};
}
class MusicQueueService extends ChangeNotifier {
static final MusicQueueService _instance = MusicQueueService._internal();
factory MusicQueueService() => _instance;
MusicQueueService._internal();
final SpotifyService _spotifyService = SpotifyService();
final AudioService _audioService = AudioService();
// Current playing track
SpotifyTrack? _currentTrack;
// Queue management
final List<QueueItem> _queue = [];
// Recently played
final List<SpotifyTrack> _recentlyPlayed = [];
// Getters
SpotifyTrack? get currentTrack => _audioService.currentTrack ?? _currentTrack;
bool get isPlaying => _audioService.isPlaying;
double get progress => _audioService.progress;
List<QueueItem> get queue => List.unmodifiable(_queue);
List<SpotifyTrack> get recentlyPlayed => List.unmodifiable(_recentlyPlayed);
// Queue operations
void addToQueue(SpotifyTrack track) {
// Check if track is already in queue
final existingIndex = _queue.indexWhere((item) => item.track.id == track.id);
if (existingIndex != -1) {
// If track exists, upvote it
upvote(existingIndex);
} else {
// Add new track to queue
final queueItem = QueueItem(track: track);
_queue.add(queueItem);
_sortQueue();
notifyListeners();
print('Added "${track.name}" by ${track.artistNames} to queue');
}
}
void upvote(int index) {
if (index >= 0 && index < _queue.length) {
_queue[index].votes++;
if (!_queue[index].userVoted) {
_queue[index].userVoted = true;
}
_sortQueue();
notifyListeners();
}
}
void downvote(int index) {
if (index >= 0 && index < _queue.length) {
if (_queue[index].votes > 0) {
_queue[index].votes--;
}
_sortQueue();
notifyListeners();
}
}
void _sortQueue() {
_queue.sort((a, b) {
// First sort by votes (descending)
final voteComparison = b.votes.compareTo(a.votes);
if (voteComparison != 0) return voteComparison;
// If votes are equal, sort by time added (ascending - first come first serve)
return a.addedAt.compareTo(b.addedAt);
});
}
// Playback control
Future<void> playNext() async {
if (_queue.isNotEmpty) {
final nextItem = _queue.removeAt(0);
_currentTrack = nextItem.track;
// Use audio service to actually play the track
await _audioService.playTrack(nextItem.track);
// Add to recently played
_recentlyPlayed.insert(0, nextItem.track);
if (_recentlyPlayed.length > 10) {
_recentlyPlayed.removeLast();
}
notifyListeners();
print('Now playing: ${_currentTrack!.name} by ${_currentTrack!.artistNames}');
}
}
Future<void> togglePlayPause() async {
await _audioService.togglePlayPause();
notifyListeners();
}
Future<void> skipTrack() async {
await playNext();
}
Future<void> seekTo(double position) async {
if (_audioService.totalDuration != Duration.zero) {
final seekPosition = Duration(
milliseconds: (position * _audioService.totalDuration.inMilliseconds).round(),
);
await _audioService.seekTo(seekPosition);
}
}
// Initialize with some popular tracks
Future<void> initializeQueue() async {
if (_queue.isEmpty && _currentTrack == null) {
try {
final popularTracks = await _spotifyService.getPopularTracks();
for (final track in popularTracks.take(4)) {
final queueItem = QueueItem(
track: track,
votes: 10 - popularTracks.indexOf(track) * 2, // Decreasing votes
userVoted: false,
);
_queue.add(queueItem);
}
// Set up audio service callback for track completion
_audioService.onTrackComplete = () {
playNext();
};
// Start playing the first track
if (_queue.isNotEmpty) {
await playNext();
}
notifyListeners();
} catch (e) {
print('Error initializing queue: $e');
}
}
}
// Search functionality
Future<List<SpotifyTrack>> searchTracks(String query) async {
return await _spotifyService.searchTracks(query, limit: 20);
}
// Get queue as JSON for display
List<Map<String, dynamic>> get queueAsJson {
return _queue.map((item) => item.toJson()).toList();
}
// Get current track info for display
Map<String, dynamic>? get currentTrackInfo {
final track = currentTrack;
if (track == null) return null;
return {
'title': track.name,
'artist': track.artistNames,
'album': track.album.name,
'imageUrl': track.imageUrl,
'duration': _audioService.totalDurationString,
'currentTime': _audioService.currentPositionString,
'progress': progress,
'isPlaying': isPlaying,
};
}
}

View file

@ -0,0 +1,608 @@
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';
import 'music_queue_service.dart';
class NetworkUser {
final String id;
final String name;
final String ipAddress;
final DateTime joinedAt;
final int votes;
bool isOnline;
DateTime lastSeen;
String? currentTrackId;
String? currentTrackName;
String? currentArtist;
String? currentTrackImage;
bool isListening;
NetworkUser({
required this.id,
required this.name,
required this.ipAddress,
required this.joinedAt,
this.votes = 0,
this.isOnline = true,
DateTime? lastSeen,
this.currentTrackId,
this.currentTrackName,
this.currentArtist,
this.currentTrackImage,
this.isListening = false,
}) : lastSeen = lastSeen ?? DateTime.now();
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'ipAddress': ipAddress,
'joinedAt': joinedAt.toIso8601String(),
'votes': votes,
'isOnline': isOnline,
'lastSeen': lastSeen.toIso8601String(),
'currentTrackId': currentTrackId,
'currentTrackName': currentTrackName,
'currentArtist': currentArtist,
'currentTrackImage': currentTrackImage,
'isListening': isListening,
};
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']),
currentTrackId: json['currentTrackId'],
currentTrackName: json['currentTrackName'],
currentArtist: json['currentArtist'],
currentTrackImage: json['currentTrackImage'],
isListening: json['isListening'] ?? false,
);
}
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;
MusicQueueService? _musicService;
// 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();
// Initialize music service reference
_musicService = MusicQueueService();
}
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') {
// Update current user with latest listening info before sending
await _updateCurrentUserListeningInfo();
// 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 if (request.method == 'POST' && request.uri.path == '/join-session') {
// Handle request to join this user's listening session
final body = await utf8.decoder.bind(request).join();
// Parse request data for future use (logging, analytics, etc.)
jsonDecode(body);
// Get current track info to send back
final currentTrackInfo = _musicService?.currentTrackInfo;
if (currentTrackInfo != null) {
response.write(jsonEncode({
'status': 'ok',
'trackInfo': currentTrackInfo,
'message': 'Successfully joined listening session'
}));
} else {
response.write(jsonEncode({
'status': 'no_track',
'message': 'No track currently playing'
}));
}
} else if (request.method == 'GET' && request.uri.path == '/current-track') {
// Get current track info without joining
await _updateCurrentUserListeningInfo();
final currentTrackInfo = _musicService?.currentTrackInfo;
if (currentTrackInfo != null) {
response.write(jsonEncode({
'status': 'ok',
'trackInfo': currentTrackInfo
}));
} else {
response.write(jsonEncode({
'status': 'no_track',
'message': 'No track currently playing'
}));
}
} 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();
}
}
Future<void> _updateCurrentUserListeningInfo() async {
final currentTrackInfo = _musicService?.currentTrackInfo;
final currentTrack = _musicService?.currentTrack;
if (currentTrack != null && currentTrackInfo != null) {
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _currentUser.ipAddress,
joinedAt: _currentUser.joinedAt,
votes: _currentUser.votes,
isOnline: true,
currentTrackId: currentTrack.id,
currentTrackName: currentTrack.name,
currentArtist: currentTrack.artistNames,
currentTrackImage: currentTrack.imageUrl,
isListening: currentTrackInfo['isPlaying'] ?? false,
);
} else {
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _currentUser.ipAddress,
joinedAt: _currentUser.joinedAt,
votes: _currentUser.votes,
isOnline: true,
isListening: false,
);
}
_networkUsers[_currentUser.id] = _currentUser;
}
// 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';
}
// Join another user's listening session
Future<bool> joinListeningSession(NetworkUser user) async {
if (!user.isListening || user.currentTrackId == null) {
return false;
}
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.postUrl(
Uri.parse('http://${user.ipAddress}:$_discoveryPort/join-session')
);
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'userId': _currentUser.id}));
final response = await request.close().timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final body = await utf8.decoder.bind(response).join();
final responseData = jsonDecode(body);
if (responseData['status'] == 'ok' && responseData['trackInfo'] != null) {
// Here you could sync the track with your local player
// For now, we'll just return success
return true;
}
}
client.close();
return false;
} catch (e) {
if (kDebugMode) {
print('Error joining listening session: $e');
}
return false;
}
}
// 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,255 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
class SpamProtectionService extends ChangeNotifier {
// Vote limits per user
static const int maxVotesPerHour = 20;
static const int maxVotesPerMinute = 5;
static const int maxSuggestionsPerHour = 10;
static const int maxSuggestionsPerMinute = 3;
// Cooldown periods (in seconds)
static const int voteCooldown = 2;
static const int suggestionCooldown = 10;
// User activity tracking
final Map<String, List<DateTime>> _userVotes = {};
final Map<String, List<DateTime>> _userSuggestions = {};
final Map<String, DateTime> _lastVoteTime = {};
final Map<String, DateTime> _lastSuggestionTime = {};
final Map<String, int> _consecutiveActions = {};
// Blocked users (temporary)
final Map<String, DateTime> _blockedUsers = {};
String getCurrentUserId() {
// In a real app, this would come from authentication
return 'current_user_${DateTime.now().millisecondsSinceEpoch ~/ 1000000}';
}
// Check if user can vote
bool canVote(String userId) {
// Check if user is temporarily blocked
if (_isUserBlocked(userId)) {
return false;
}
// Allow first vote without cooldown for smooth user experience
final votes = _userVotes[userId] ?? [];
if (votes.isEmpty) {
return true; // First vote is always allowed
}
// Check cooldown for subsequent votes
if (_isOnCooldown(userId, _lastVoteTime, voteCooldown)) {
return false;
}
// Check rate limits
if (!_checkRateLimit(userId, _userVotes, maxVotesPerMinute, maxVotesPerHour)) {
return false;
}
return true;
}
// Check if user can suggest songs
bool canSuggest(String userId) {
// Check if user is temporarily blocked
if (_isUserBlocked(userId)) {
return false;
}
// Allow first suggestion without cooldown for smooth user experience
final suggestions = _userSuggestions[userId] ?? [];
if (suggestions.isEmpty) {
return true; // First suggestion is always allowed
}
// Check cooldown for subsequent suggestions
if (_isOnCooldown(userId, _lastSuggestionTime, suggestionCooldown)) {
return false;
}
// Check rate limits
if (!_checkRateLimit(userId, _userSuggestions, maxSuggestionsPerMinute, maxSuggestionsPerHour)) {
return false;
}
return true;
}
// Record a vote action
void recordVote(String userId) {
final now = DateTime.now();
// Add to vote history
_userVotes.putIfAbsent(userId, () => []).add(now);
_lastVoteTime[userId] = now;
// Track consecutive actions for spam detection
_incrementConsecutiveActions(userId);
// Clean old entries
_cleanOldEntries(_userVotes[userId]!);
// Check for suspicious behavior
_checkForSpam(userId);
notifyListeners();
}
// Record a suggestion action
void recordSuggestion(String userId) {
final now = DateTime.now();
// Add to suggestion history
_userSuggestions.putIfAbsent(userId, () => []).add(now);
_lastSuggestionTime[userId] = now;
// Track consecutive actions for spam detection
_incrementConsecutiveActions(userId);
// Clean old entries
_cleanOldEntries(_userSuggestions[userId]!);
// Check for suspicious behavior
_checkForSpam(userId);
notifyListeners();
}
// Get remaining cooldown time in seconds
int getVoteCooldownRemaining(String userId) {
final lastVote = _lastVoteTime[userId];
if (lastVote == null) return 0;
final elapsed = DateTime.now().difference(lastVote).inSeconds;
return (voteCooldown - elapsed).clamp(0, voteCooldown);
}
int getSuggestionCooldownRemaining(String userId) {
final lastSuggestion = _lastSuggestionTime[userId];
if (lastSuggestion == null) return 0;
final elapsed = DateTime.now().difference(lastSuggestion).inSeconds;
return (suggestionCooldown - elapsed).clamp(0, suggestionCooldown);
}
// Get user activity stats
Map<String, int> getUserStats(String userId) {
final now = DateTime.now();
final hourAgo = now.subtract(const Duration(hours: 1));
final minuteAgo = now.subtract(const Duration(minutes: 1));
final votesThisHour = _userVotes[userId]?.where((time) => time.isAfter(hourAgo)).length ?? 0;
final votesThisMinute = _userVotes[userId]?.where((time) => time.isAfter(minuteAgo)).length ?? 0;
final suggestionsThisHour = _userSuggestions[userId]?.where((time) => time.isAfter(hourAgo)).length ?? 0;
final suggestionsThisMinute = _userSuggestions[userId]?.where((time) => time.isAfter(minuteAgo)).length ?? 0;
return {
'votesThisHour': votesThisHour,
'votesThisMinute': votesThisMinute,
'suggestionsThisHour': suggestionsThisHour,
'suggestionsThisMinute': suggestionsThisMinute,
'maxVotesPerHour': maxVotesPerHour,
'maxVotesPerMinute': maxVotesPerMinute,
'maxSuggestionsPerHour': maxSuggestionsPerHour,
'maxSuggestionsPerMinute': maxSuggestionsPerMinute,
};
}
// Check if user is blocked
bool _isUserBlocked(String userId) {
final blockTime = _blockedUsers[userId];
if (blockTime == null) return false;
// Unblock after 5 minutes
if (DateTime.now().difference(blockTime).inMinutes >= 5) {
_blockedUsers.remove(userId);
return false;
}
return true;
}
// Check cooldown
bool _isOnCooldown(String userId, Map<String, DateTime> lastActionTime, int cooldownSeconds) {
final lastAction = lastActionTime[userId];
if (lastAction == null) return false;
return DateTime.now().difference(lastAction).inSeconds < cooldownSeconds;
}
// Check rate limits
bool _checkRateLimit(String userId, Map<String, List<DateTime>> userActions, int maxPerMinute, int maxPerHour) {
final actions = userActions[userId] ?? [];
final now = DateTime.now();
// Count actions in the last minute
final actionsLastMinute = actions.where((time) =>
now.difference(time).inMinutes < 1).length;
if (actionsLastMinute >= maxPerMinute) return false;
// Count actions in the last hour
final actionsLastHour = actions.where((time) =>
now.difference(time).inHours < 1).length;
if (actionsLastHour >= maxPerHour) return false;
return true;
}
// Clean old entries (older than 1 hour)
void _cleanOldEntries(List<DateTime> entries) {
final oneHourAgo = DateTime.now().subtract(const Duration(hours: 1));
entries.removeWhere((time) => time.isBefore(oneHourAgo));
}
// Track consecutive actions for spam detection
void _incrementConsecutiveActions(String userId) {
_consecutiveActions[userId] = (_consecutiveActions[userId] ?? 0) + 1;
// Reset after some time of inactivity
Timer(const Duration(seconds: 30), () {
_consecutiveActions[userId] = 0;
});
}
// Check for spam behavior and block if necessary
void _checkForSpam(String userId) {
final consecutive = _consecutiveActions[userId] ?? 0;
// Block user if too many consecutive actions
if (consecutive > 15) {
_blockedUsers[userId] = DateTime.now();
if (kDebugMode) {
print('User $userId temporarily blocked for spam behavior');
}
}
}
// Get block status message
String? getBlockMessage(String userId) {
final blockTime = _blockedUsers[userId];
if (blockTime == null) return null;
final remaining = 5 - DateTime.now().difference(blockTime).inMinutes;
if (remaining <= 0) {
_blockedUsers.remove(userId);
return null;
}
return 'You are temporarily blocked for $remaining more minutes due to excessive activity.';
}
// Clear all data (for testing)
void clearAllData() {
_userVotes.clear();
_userSuggestions.clear();
_lastVoteTime.clear();
_lastSuggestionTime.clear();
_consecutiveActions.clear();
_blockedUsers.clear();
notifyListeners();
}
}

View file

@ -0,0 +1,224 @@
// Spotify API Credentials
//
// SETUP INSTRUCTIONS:
// 1. Go to https://developer.spotify.com/dashboard
// 2. Log in with your Spotify account
// 3. Create a new app called "SleepySound"
// 4. Copy your Client ID and Client Secret below
// 5. Save this file
//
// SECURITY NOTE: Never commit real credentials to version control!
// For production, use environment variables or secure storage.
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/spotify_track.dart';
import 'SPOTIFY_SECRET.dart';
class SpotifyService {
// Load credentials from the secret file
static String get _clientId => SpotifyCredentials.clientId;
static String get _clientSecret => SpotifyCredentials.clientSecret;
static const String _baseUrl = 'https://api.spotify.com/v1';
static const String _authUrl = 'https://accounts.spotify.com/api/token';
String? _accessToken;
// Check if valid credentials are provided
bool get _hasValidCredentials =>
_clientId != 'YOUR_SPOTIFY_CLIENT_ID' &&
_clientSecret != 'YOUR_SPOTIFY_CLIENT_SECRET' &&
_clientId.isNotEmpty &&
_clientSecret.isNotEmpty;
// For demo purposes, we'll use Client Credentials flow (no user login required)
// In a real app, you'd want to implement Authorization Code flow for user-specific features
Future<void> _getAccessToken() async {
// Check if we have valid credentials first
if (!_hasValidCredentials) {
print('No valid Spotify credentials found. Using demo data.');
return;
}
try {
final response = await http.post(
Uri.parse(_authUrl),
headers: {
'Authorization': 'Basic ${base64Encode(utf8.encode('$_clientId:$_clientSecret'))}',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials',
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
_accessToken = data['access_token'];
// Save token to shared preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString('spotify_access_token', _accessToken!);
print('Spotify access token obtained successfully');
} else {
print('Failed to get Spotify access token: ${response.statusCode}');
print('Response body: ${response.body}');
}
} catch (e) {
print('Error getting Spotify access token: $e');
}
}
Future<void> _ensureValidToken() async {
// If no valid credentials, skip token generation
if (!_hasValidCredentials) {
return;
}
if (_accessToken == null) {
// Try to load from shared preferences first
final prefs = await SharedPreferences.getInstance();
_accessToken = prefs.getString('spotify_access_token');
if (_accessToken == null) {
await _getAccessToken();
}
}
}
Future<List<SpotifyTrack>> searchTracks(String query, {int limit = 20}) async {
try {
await _ensureValidToken();
// If no valid credentials or token, use demo data
if (!_hasValidCredentials || _accessToken == null) {
print('Using demo data for search: $query');
return _getDemoTracks(query);
}
final encodedQuery = Uri.encodeQueryComponent(query);
final response = await http.get(
Uri.parse('$_baseUrl/search?q=$encodedQuery&type=track&limit=$limit'),
headers: {
'Authorization': 'Bearer $_accessToken',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final searchResponse = SpotifySearchResponse.fromJson(data);
print('Found ${searchResponse.tracks.items.length} tracks from Spotify API');
return searchResponse.tracks.items;
} else if (response.statusCode == 401) {
// Token expired, get a new one
_accessToken = null;
await _getAccessToken();
return searchTracks(query, limit: limit); // Retry
} else {
print('Spotify search failed: ${response.statusCode}');
print('Response body: ${response.body}');
return _getDemoTracks(query);
}
} catch (e) {
print('Error searching Spotify: $e');
return _getDemoTracks(query);
}
}
Future<List<SpotifyTrack>> getPopularTracks({String genre = 'chill'}) async {
try {
await _ensureValidToken();
if (!_hasValidCredentials || _accessToken == null) {
print('Using demo popular tracks');
return _getDemoPopularTracks();
}
// Search for popular tracks in the genre
final response = await http.get(
Uri.parse('$_baseUrl/search?q=genre:$genre&type=track&limit=10'),
headers: {
'Authorization': 'Bearer $_accessToken',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final searchResponse = SpotifySearchResponse.fromJson(data);
return searchResponse.tracks.items;
} else {
return _getDemoPopularTracks();
}
} catch (e) {
print('Error getting popular tracks: $e');
return _getDemoPopularTracks();
}
}
// Demo data for when Spotify API is not available
List<SpotifyTrack> _getDemoTracks(String query) {
final demoTracks = [
_createDemoTrack('1', 'Tropical House Cruises', 'Kygo', 'Cloud Nine', 'https://i.scdn.co/image/tropical'),
_createDemoTrack('2', 'Summer Breeze', 'Seeb', 'Summer Hits', 'https://i.scdn.co/image/summer'),
_createDemoTrack('3', 'Relaxing Waves', 'Chillhop Music', 'Chill Collection', 'https://i.scdn.co/image/waves'),
_createDemoTrack('4', 'Sunset Vibes', 'Odesza', 'In Return', 'https://i.scdn.co/image/sunset'),
_createDemoTrack('5', 'Ocean Dreams', 'Emancipator', 'Soon It Will Be Cold Enough', 'https://i.scdn.co/image/ocean'),
];
// Filter based on query
if (query.toLowerCase().contains('tropical') || query.toLowerCase().contains('kygo')) {
return [demoTracks[0]];
} else if (query.toLowerCase().contains('summer')) {
return [demoTracks[1]];
} else if (query.toLowerCase().contains('chill') || query.toLowerCase().contains('relax')) {
return [demoTracks[2], demoTracks[4]];
} else if (query.toLowerCase().contains('sunset')) {
return [demoTracks[3]];
}
return demoTracks;
}
List<SpotifyTrack> _getDemoPopularTracks() {
return [
_createDemoTrack('pop1', 'Ocean Breeze', 'Lofi Dreams', 'Summer Collection', 'https://i.scdn.co/image/ocean'),
_createDemoTrack('pop2', 'Sunset Melody', 'Acoustic Soul', 'Peaceful Moments', 'https://i.scdn.co/image/sunset'),
_createDemoTrack('pop3', 'Peaceful Waters', 'Nature Sounds', 'Tranquil Vibes', 'https://i.scdn.co/image/water'),
_createDemoTrack('pop4', 'Summer Nights', 'Chill Vibes', 'Evening Sessions', 'https://i.scdn.co/image/night'),
];
}
SpotifyTrack _createDemoTrack(String id, String name, String artistName, String albumName, String imageUrl) {
return SpotifyTrack(
id: id,
name: name,
artists: [SpotifyArtist(id: 'artist_$id', name: artistName)],
album: SpotifyAlbum(
id: 'album_$id',
name: albumName,
images: [SpotifyImage(height: 640, width: 640, url: imageUrl)],
),
durationMs: 210000 + (id.hashCode % 120000), // Random duration between 3:30 and 5:30
externalUrls: {'spotify': 'https://open.spotify.com/track/$id'},
previewUrl: null,
);
}
// Method to check if Spotify API is properly configured
static bool get isConfigured =>
SpotifyCredentials.clientId != 'YOUR_SPOTIFY_CLIENT_ID' &&
SpotifyCredentials.clientSecret != 'YOUR_SPOTIFY_CLIENT_SECRET' &&
SpotifyCredentials.clientId.isNotEmpty &&
SpotifyCredentials.clientSecret.isNotEmpty;
// Method to get configuration status for UI display
static String get configurationStatus {
if (isConfigured) {
return 'Spotify API configured ✓';
} else {
return 'Using demo data (Spotify not configured)';
}
}
}

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

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../services/spam_protection_service.dart';
class UserActivityStatus extends StatelessWidget {
final SpamProtectionService spamService;
const UserActivityStatus({
super.key,
required this.spamService,
});
@override
Widget build(BuildContext context) {
final userId = spamService.getCurrentUserId();
final stats = spamService.getUserStats(userId);
final blockMessage = spamService.getBlockMessage(userId);
if (blockMessage != null) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.block, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
blockMessage,
style: const TextStyle(color: Colors.red, fontSize: 14),
),
),
],
),
);
}
// Show activity limits if user is getting close
final votesUsed = stats['votesThisHour']!;
final suggestionsUsed = stats['suggestionsThisHour']!;
final maxVotes = stats['maxVotesPerHour']!;
final maxSuggestions = stats['maxSuggestionsPerHour']!;
final showWarning = votesUsed > maxVotes * 0.8 || suggestionsUsed > maxSuggestions * 0.8;
if (!showWarning) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.warning, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text(
'Activity Limits',
style: TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Votes: $votesUsed/$maxVotes',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
LinearProgressIndicator(
value: votesUsed / maxVotes,
backgroundColor: Colors.grey.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
votesUsed > maxVotes * 0.9 ? Colors.red : Colors.orange,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Suggestions: $suggestionsUsed/$maxSuggestions',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
LinearProgressIndicator(
value: suggestionsUsed / maxSuggestions,
backgroundColor: Colors.grey.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
suggestionsUsed > maxSuggestions * 0.9 ? Colors.red : Colors.orange,
),
),
],
),
),
],
),
],
),
);
}
}

View file

@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View file

@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -5,6 +5,20 @@
import FlutterMacOS
import Foundation
import audioplayers_darwin
import connectivity_plus
import network_info_plus
import path_provider_foundation
import shared_preferences_foundation
import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}

View file

@ -1,6 +1,38 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
source: hosted
version: "7.7.1"
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@ -9,6 +41,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.12.0"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: e653f162ddfcec1da2040ba2d8553fff1662b5c2a5c636f4c21a3b11bee497de
url: "https://pub.dev"
source: hosted
version: "6.5.0"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
url: "https://pub.dev"
source: hosted
version: "6.3.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
url: "https://pub.dev"
source: hosted
version: "4.2.1"
boolean_selector:
dependency: transitive
description:
@ -17,6 +105,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa"
url: "https://pub.dev"
source: hosted
version: "4.0.4"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0b1b12a0a549605e5f04476031cd0bc91ead1d7c8e830773a18ee54179b3cb62"
url: "https://pub.dev"
source: hosted
version: "8.11.0"
characters:
dependency: transitive
description:
@ -25,6 +177,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.3"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@ -33,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e"
url: "https://pub.dev"
source: hosted
version: "4.10.1"
collection:
dependency: transitive
description:
@ -41,6 +217,38 @@ 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:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
cupertino_icons:
dependency: "direct main"
description:
@ -49,6 +257,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
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:
@ -57,11 +281,43 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.2"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints:
dependency: "direct dev"
description:
@ -75,6 +331,99 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: "direct main"
description:
name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
url: "https://pub.dev"
source: hosted
version: "6.9.5"
leak_tracker:
dependency: transitive
description:
@ -107,6 +456,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@ -131,6 +488,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
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:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
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:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
@ -139,11 +552,211 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
url: "https://pub.dev"
source: hosted
version: "2.2.17"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
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:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
url: "https://pub.dev"
source: hosted
version: "2.4.10"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "4f81479fe5194a622cdd1713fe1ecb683a6e6c85cd8cec8e2e35ee5ab3fdf2a1"
url: "https://pub.dev"
source: hosted
version: "1.3.6"
source_span:
dependency: transitive
description:
@ -152,6 +765,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace:
dependency: transitive
description:
@ -168,6 +789,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@ -176,6 +805,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
term_glyph:
dependency: transitive
description:
@ -192,6 +829,94 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.4"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
url: "https://pub.dev"
source: hosted
version: "6.3.16"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
url: "https://pub.dev"
source: hosted
version: "6.3.3"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math:
dependency: transitive
description:
@ -208,6 +933,102 @@ packages:
url: "https://pub.dev"
source: hosted
version: "14.3.1"
watcher:
dependency: transitive
description:
name: watcher
sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
url: "https://pub.dev"
source: hosted
version: "4.13.0"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "9573ad97890d199ac3ab32399aa33a5412163b37feb573eb5b0a76b35e9ffe41"
url: "https://pub.dev"
source: hosted
version: "4.8.2"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
url: "https://pub.dev"
source: hosted
version: "2.14.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "71523b9048cf510cfa1fd4e0a3fa5e476a66e0884d5df51d59d5023dba237107"
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:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
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:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.7.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.27.0"

View file

@ -34,6 +34,34 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# HTTP requests
http: ^1.1.0
# JSON handling
json_annotation: ^4.8.1
# URL launcher for Spotify authentication
url_launcher: ^6.2.1
# Shared preferences for storing tokens
shared_preferences: ^2.2.2
# Web view for authentication
webview_flutter: ^4.4.2
# 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
# Audio playback
audioplayers: ^6.0.0
dev_dependencies:
flutter_test:
@ -45,6 +73,13 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
# JSON serialization
json_serializable: ^6.7.1
build_runner: ^2.4.7
# App icon generator
flutter_launcher_icons: ^0.13.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@ -58,7 +93,9 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
assets:
- assets/audio/
- assets/icons/
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
@ -87,3 +124,13 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_launcher_icons:
android: true
ios: true
web:
generate: true
image_path: "assets/icons/app_icon.png"
adaptive_icon_background: "#121212"
adaptive_icon_foreground: "assets/icons/app_icon_foreground.png"
remove_alpha_ios: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 581 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Before After
Before After

View file

@ -32,4 +32,4 @@
"purpose": "maskable"
}
]
}
}

View file

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

View file

@ -3,6 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
connectivity_plus
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST