Compare commits
11 commits
main
...
challenge-
Author | SHA1 | Date | |
---|---|---|---|
1ba28e04c2 | |||
3efab4f59b | |||
1ca5a639d5 | |||
8bc45ad6fd | |||
a91654df03 | |||
6b93f1206d | |||
025eee7644 | |||
f70fe3cdd1 | |||
1ce7aea6b5 | |||
5c84b2fb59 | |||
0749836d17 |
5
CHALLENGE_2/sleepysound/.gitignore
vendored
|
@ -1,3 +1,8 @@
|
||||||
|
# API Secret Spotify
|
||||||
|
SPOTIFY_SECRET.dart
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
*.class
|
*.class
|
||||||
*.log
|
*.log
|
||||||
|
|
133
CHALLENGE_2/sleepysound/README_SPOTIFY.md
Normal 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! 🎶
|
68
CHALLENGE_2/sleepysound/SPOTIFY_SETUP.md
Normal 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! 🎶
|
BIN
CHALLENGE_2/sleepysound/SleepySound.apk
Normal file
|
@ -8,7 +8,7 @@ plugins {
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.sleepysound"
|
namespace = "com.example.sleepysound"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = "27.0.12077973"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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
|
<application
|
||||||
android:label="sleepysound"
|
android:label="sleepysound"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 41 KiB |
After Width: | Height: | Size: 104 KiB |
After Width: | Height: | Size: 206 KiB |
|
@ -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>
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 5.6 KiB |
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 34 KiB |
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#121212</color>
|
||||||
|
</resources>
|
BIN
CHALLENGE_2/sleepysound/assets/icons/app_icon.png
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
CHALLENGE_2/sleepysound/assets/icons/app_icon_foreground.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
|
@ -427,7 +427,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
@ -484,7 +484,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 780 B |
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 9.7 KiB |
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 5.6 KiB |
After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
|
@ -1,4 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
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() {
|
void main() {
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
|
@ -10,20 +18,42 @@ class MyApp extends StatelessWidget {
|
||||||
// This widget is the root of your application.
|
// This widget is the root of your application.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MultiProvider(
|
||||||
title: 'SleepySound',
|
providers: [
|
||||||
theme: ThemeData(
|
ChangeNotifierProvider(create: (context) => MusicQueueService()),
|
||||||
|
ChangeNotifierProvider(create: (context) => NetworkGroupService()),
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
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 {
|
class MyHomePage extends StatefulWidget {
|
||||||
const MyHomePage({super.key, required this.title});
|
const MyHomePage({super.key, required this.title});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -31,69 +61,53 @@ class MyHomePage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
int _counter = 0;
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
void _incrementCounter() {
|
void _onItemTapped(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
// This call to setState tells the Flutter framework that something has
|
_selectedIndex = index;
|
||||||
// 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++;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _getSelectedPage() {
|
||||||
|
switch (_selectedIndex) {
|
||||||
|
case 0:
|
||||||
|
return const NowPlayingPage();
|
||||||
|
case 1:
|
||||||
|
return const VotingPage();
|
||||||
|
case 2:
|
||||||
|
return const GroupPage();
|
||||||
|
default:
|
||||||
|
return const NowPlayingPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
body: _getSelectedPage(),
|
||||||
// TRY THIS: Try changing the color here to a specific color (to
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
items: const <BottomNavigationBarItem>[
|
||||||
// change color while the other colors stay the same.
|
BottomNavigationBarItem(
|
||||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
icon: Icon(Icons.play_circle_filled),
|
||||||
// Here we take the value from the MyHomePage object that was created by
|
label: 'Now Playing',
|
||||||
// the App.build method, and use it to set our appbar title.
|
),
|
||||||
title: Text(widget.title),
|
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.
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
122
CHALLENGE_2/sleepysound/lib/models/spotify_track.dart
Normal 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);
|
||||||
|
}
|
88
CHALLENGE_2/sleepysound/lib/models/spotify_track.g.dart
Normal 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};
|
517
CHALLENGE_2/sleepysound/lib/pages/group_page.dart
Normal 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'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
CHALLENGE_2/sleepysound/lib/pages/library_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
317
CHALLENGE_2/sleepysound/lib/pages/now_playing_page.dart
Normal 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')}';
|
||||||
|
}
|
||||||
|
}
|
34
CHALLENGE_2/sleepysound/lib/pages/settings_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
793
CHALLENGE_2/sleepysound/lib/pages/voting_page.dart
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
491
CHALLENGE_2/sleepysound/lib/pages/voting_page_new.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
242
CHALLENGE_2/sleepysound/lib/services/audio_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
288
CHALLENGE_2/sleepysound/lib/services/genre_filter_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
200
CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
608
CHALLENGE_2/sleepysound/lib/services/network_group_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
224
CHALLENGE_2/sleepysound/lib/services/spotify_service.dart
Normal 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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
139
CHALLENGE_2/sleepysound/lib/widgets/network_demo_widget.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
250
CHALLENGE_2/sleepysound/lib/widgets/network_widgets.dart
Normal 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'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
125
CHALLENGE_2/sleepysound/lib/widgets/user_activity_status.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,14 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#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) {
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
audioplayers_linux
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
@ -5,6 +5,20 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
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) {
|
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"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,38 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
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:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -9,6 +41,62 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.12.0"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -17,6 +105,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -25,6 +177,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -33,6 +201,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -41,6 +217,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -49,6 +257,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -57,11 +281,43 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.2"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
@ -75,6 +331,99 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -107,6 +456,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.1"
|
version: "5.1.1"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -131,6 +488,62 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -139,11 +552,211 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -152,6 +765,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.1"
|
||||||
|
sprintf:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sprintf
|
||||||
|
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.0"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -168,6 +789,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
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:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -176,6 +805,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.3.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -192,6 +829,94 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -208,6 +933,102 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.3.1"
|
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:
|
sdks:
|
||||||
dart: ">=3.7.2 <4.0.0"
|
dart: ">=3.7.2 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.27.0"
|
||||||
|
|
|
@ -34,6 +34,34 @@ dependencies:
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
@ -45,6 +73,13 @@ dev_dependencies:
|
||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^5.0.0
|
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
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
@ -58,7 +93,9 @@ flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# 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_burr.jpeg
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
@ -87,3 +124,13 @@ flutter:
|
||||||
#
|
#
|
||||||
# For details regarding fonts from package dependencies,
|
# For details regarding fonts from package dependencies,
|
||||||
# see https://flutter.dev/to/font-from-package
|
# 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
|
||||||
|
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 581 B |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 312 KiB |
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 312 KiB |
|
@ -32,4 +32,4 @@
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -6,6 +6,15 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#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) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||||
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,9 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
audioplayers_windows
|
||||||
|
connectivity_plus
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|