diff --git a/CHALLENGE_2/sleepysound/.gitignore b/CHALLENGE_2/sleepysound/.gitignore index 79c113f..504aa6b 100644 --- a/CHALLENGE_2/sleepysound/.gitignore +++ b/CHALLENGE_2/sleepysound/.gitignore @@ -1,3 +1,8 @@ +# API Secret Spotify +SPOTIFY_SECRET.dart + + + # Miscellaneous *.class *.log diff --git a/CHALLENGE_2/sleepysound/README_SPOTIFY.md b/CHALLENGE_2/sleepysound/README_SPOTIFY.md new file mode 100644 index 0000000..3b9bd30 --- /dev/null +++ b/CHALLENGE_2/sleepysound/README_SPOTIFY.md @@ -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! 🎶 diff --git a/CHALLENGE_2/sleepysound/SPOTIFY_SETUP.md b/CHALLENGE_2/sleepysound/SPOTIFY_SETUP.md new file mode 100644 index 0000000..23e493c --- /dev/null +++ b/CHALLENGE_2/sleepysound/SPOTIFY_SETUP.md @@ -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! 🎶 diff --git a/CHALLENGE_2/sleepysound/SleepySound.apk b/CHALLENGE_2/sleepysound/SleepySound.apk new file mode 100644 index 0000000..0cc9bc2 Binary files /dev/null and b/CHALLENGE_2/sleepysound/SleepySound.apk differ diff --git a/CHALLENGE_2/sleepysound/android/app/build.gradle.kts b/CHALLENGE_2/sleepysound/android/app/build.gradle.kts index e2da798..7e83ad8 100644 --- a/CHALLENGE_2/sleepysound/android/app/build.gradle.kts +++ b/CHALLENGE_2/sleepysound/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.sleepysound" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/CHALLENGE_2/sleepysound/android/app/src/main/AndroidManifest.xml b/CHALLENGE_2/sleepysound/android/app/src/main/AndroidManifest.xml index d86582e..b931811 100644 --- a/CHALLENGE_2/sleepysound/android/app/src/main/AndroidManifest.xml +++ b/CHALLENGE_2/sleepysound/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ + + + + + + + + + + + + diff --git a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..3e80a2d 100644 Binary files a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..6bbfdac 100644 Binary files a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..aa53945 100644 Binary files a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..421186e 100644 Binary files a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..f087383 100644 Binary files a/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/CHALLENGE_2/sleepysound/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/CHALLENGE_2/sleepysound/android/app/src/main/res/values/colors.xml b/CHALLENGE_2/sleepysound/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3b44edc --- /dev/null +++ b/CHALLENGE_2/sleepysound/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #121212 + \ No newline at end of file diff --git a/CHALLENGE_2/sleepysound/assets/icons/app_icon.png b/CHALLENGE_2/sleepysound/assets/icons/app_icon.png new file mode 100644 index 0000000..1fd3697 Binary files /dev/null and b/CHALLENGE_2/sleepysound/assets/icons/app_icon.png differ diff --git a/CHALLENGE_2/sleepysound/assets/icons/app_icon_foreground.png b/CHALLENGE_2/sleepysound/assets/icons/app_icon_foreground.png new file mode 100644 index 0000000..c8713ac Binary files /dev/null and b/CHALLENGE_2/sleepysound/assets/icons/app_icon_foreground.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner.xcodeproj/project.pbxproj b/CHALLENGE_2/sleepysound/ios/Runner.xcodeproj/project.pbxproj index f9b1ec5..bfa417c 100644 --- a/CHALLENGE_2/sleepysound/ios/Runner.xcodeproj/project.pbxproj +++ b/CHALLENGE_2/sleepysound/ios/Runner.xcodeproj/project.pbxproj @@ -427,7 +427,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -484,7 +484,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..b0c09f7 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..3a08db0 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..5cbcf61 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..1e78f97 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..37c653f 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..3875cf2 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..4dd58ad 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..5cbcf61 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..a2269a5 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..082a070 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..a729965 Binary files /dev/null and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..487e67d Binary files /dev/null and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..0db5bbf Binary files /dev/null and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..3ad426d Binary files /dev/null and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..082a070 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..86f6399 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..3e80a2d Binary files /dev/null and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..421186e Binary files /dev/null and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..c8db051 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..fd632fe 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..837bbac 100644 Binary files a/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/CHALLENGE_2/sleepysound/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/CHALLENGE_2/sleepysound/lib/main.dart b/CHALLENGE_2/sleepysound/lib/main.dart index 419d6ca..117b8e6 100644 --- a/CHALLENGE_2/sleepysound/lib/main.dart +++ b/CHALLENGE_2/sleepysound/lib/main.dart @@ -1,4 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'pages/now_playing_page.dart'; +import 'pages/voting_page.dart'; +import 'pages/group_page.dart'; +import 'services/music_queue_service.dart'; +import 'services/network_group_service.dart'; +import 'services/spam_protection_service.dart'; +import 'services/audio_service.dart'; void main() { runApp(const MyApp()); @@ -10,20 +18,42 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( - title: 'SleepySound', - theme: ThemeData( - - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => MusicQueueService()), + ChangeNotifierProvider(create: (context) => NetworkGroupService()), + ChangeNotifierProvider(create: (context) => SpamProtectionService()), + ChangeNotifierProvider(create: (context) => AudioService()), + ], + child: MaterialApp( + title: 'LidoSound', + theme: ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6366F1), + brightness: Brightness.dark, + ), + scaffoldBackgroundColor: const Color(0xFF121212), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF1E1E1E), + foregroundColor: Colors.white, + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: Color(0xFF1E1E1E), + selectedItemColor: Color(0xFF6366F1), + unselectedItemColor: Colors.grey, + type: BottomNavigationBarType.fixed, + ), + ), + home: const MyHomePage(title: 'Now Playing'), ), - home: const MyHomePage(title: 'Now Playing'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); - final String title; @override @@ -31,69 +61,53 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - int _counter = 0; + int _selectedIndex = 0; - void _incrementCounter() { + void _onItemTapped(int index) { setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; + _selectedIndex = index; }); } + Widget _getSelectedPage() { + switch (_selectedIndex) { + case 0: + return const NowPlayingPage(); + case 1: + return const VotingPage(); + case 2: + return const GroupPage(); + default: + return const NowPlayingPage(); + } + } + @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), + body: _getSelectedPage(), + bottomNavigationBar: BottomNavigationBar( + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.play_circle_filled), + label: 'Now Playing', + ), + BottomNavigationBarItem( + icon: Icon(Icons.how_to_vote), + label: 'Voting', + ), + BottomNavigationBarItem( + icon: Icon(Icons.group), + label: 'Group', + ), + ], + currentIndex: _selectedIndex, + backgroundColor: const Color(0xFF1E1E1E), + selectedItemColor: const Color(0xFF6366F1), + unselectedItemColor: Colors.grey, + type: BottomNavigationBarType.fixed, + onTap: _onItemTapped, ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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. ); } } diff --git a/CHALLENGE_2/sleepysound/lib/models/spotify_track.dart b/CHALLENGE_2/sleepysound/lib/models/spotify_track.dart new file mode 100644 index 0000000..6ccbdeb --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/models/spotify_track.dart @@ -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 artists; + final SpotifyAlbum album; + @JsonKey(name: 'duration_ms') + final int durationMs; + @JsonKey(name: 'external_urls') + final Map 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 json) => + _$SpotifyTrackFromJson(json); + + Map 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 json) => + _$SpotifyArtistFromJson(json); + + Map toJson() => _$SpotifyArtistToJson(this); +} + +@JsonSerializable() +class SpotifyAlbum { + final String id; + final String name; + final List images; + + SpotifyAlbum({ + required this.id, + required this.name, + required this.images, + }); + + factory SpotifyAlbum.fromJson(Map json) => + _$SpotifyAlbumFromJson(json); + + Map 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 json) => + _$SpotifyImageFromJson(json); + + Map toJson() => _$SpotifyImageToJson(this); +} + +@JsonSerializable() +class SpotifySearchResponse { + final SpotifyTracks tracks; + + SpotifySearchResponse({required this.tracks}); + + factory SpotifySearchResponse.fromJson(Map json) => + _$SpotifySearchResponseFromJson(json); + + Map toJson() => _$SpotifySearchResponseToJson(this); +} + +@JsonSerializable() +class SpotifyTracks { + final List items; + final int total; + + SpotifyTracks({ + required this.items, + required this.total, + }); + + factory SpotifyTracks.fromJson(Map json) => + _$SpotifyTracksFromJson(json); + + Map toJson() => _$SpotifyTracksToJson(this); +} diff --git a/CHALLENGE_2/sleepysound/lib/models/spotify_track.g.dart b/CHALLENGE_2/sleepysound/lib/models/spotify_track.g.dart new file mode 100644 index 0000000..fac476a --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/models/spotify_track.g.dart @@ -0,0 +1,88 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spotify_track.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SpotifyTrack _$SpotifyTrackFromJson(Map json) => SpotifyTrack( + id: json['id'] as String, + name: json['name'] as String, + artists: + (json['artists'] as List) + .map((e) => SpotifyArtist.fromJson(e as Map)) + .toList(), + album: SpotifyAlbum.fromJson(json['album'] as Map), + durationMs: (json['duration_ms'] as num).toInt(), + externalUrls: Map.from(json['external_urls'] as Map), + previewUrl: json['preview_url'] as String?, +); + +Map _$SpotifyTrackToJson(SpotifyTrack instance) => + { + '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 json) => + SpotifyArtist(id: json['id'] as String, name: json['name'] as String); + +Map _$SpotifyArtistToJson(SpotifyArtist instance) => + {'id': instance.id, 'name': instance.name}; + +SpotifyAlbum _$SpotifyAlbumFromJson(Map json) => SpotifyAlbum( + id: json['id'] as String, + name: json['name'] as String, + images: + (json['images'] as List) + .map((e) => SpotifyImage.fromJson(e as Map)) + .toList(), +); + +Map _$SpotifyAlbumToJson(SpotifyAlbum instance) => + { + 'id': instance.id, + 'name': instance.name, + 'images': instance.images, + }; + +SpotifyImage _$SpotifyImageFromJson(Map json) => SpotifyImage( + height: (json['height'] as num).toInt(), + width: (json['width'] as num).toInt(), + url: json['url'] as String, +); + +Map _$SpotifyImageToJson(SpotifyImage instance) => + { + 'height': instance.height, + 'width': instance.width, + 'url': instance.url, + }; + +SpotifySearchResponse _$SpotifySearchResponseFromJson( + Map json, +) => SpotifySearchResponse( + tracks: SpotifyTracks.fromJson(json['tracks'] as Map), +); + +Map _$SpotifySearchResponseToJson( + SpotifySearchResponse instance, +) => {'tracks': instance.tracks}; + +SpotifyTracks _$SpotifyTracksFromJson(Map json) => + SpotifyTracks( + items: + (json['items'] as List) + .map((e) => SpotifyTrack.fromJson(e as Map)) + .toList(), + total: (json['total'] as num).toInt(), + ); + +Map _$SpotifyTracksToJson(SpotifyTracks instance) => + {'items': instance.items, 'total': instance.total}; diff --git a/CHALLENGE_2/sleepysound/lib/pages/group_page.dart b/CHALLENGE_2/sleepysound/lib/pages/group_page.dart new file mode 100644 index 0000000..765df62 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/pages/group_page.dart @@ -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 createState() => _GroupPageState(); +} + +class _GroupPageState extends State { + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, networkService, child) { + final isConnected = networkService.isConnectedToWifi; + final networkName = networkService.currentNetworkName; + final currentUser = networkService.currentUser; + final networkUsers = networkService.networkUsers; + final onlineCount = networkService.onlineUsersCount; + + return Container( + color: const Color(0xFF121212), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // WiFi Connection Status Card + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isConnected + ? const Color(0xFF22C55E).withOpacity(0.1) + : const Color(0xFFEF4444).withOpacity(0.1), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), + width: 1, + ), + ), + 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 && 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'}'; + } + } + } +} \ No newline at end of file diff --git a/CHALLENGE_2/sleepysound/lib/pages/library_page.dart b/CHALLENGE_2/sleepysound/lib/pages/library_page.dart new file mode 100644 index 0000000..30ba4bf --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/pages/library_page.dart @@ -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), + ), + ], + ), + ), + ); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/pages/now_playing_page.dart b/CHALLENGE_2/sleepysound/lib/pages/now_playing_page.dart new file mode 100644 index 0000000..dc2144a --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/pages/now_playing_page.dart @@ -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( + 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(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')}'; + } +} diff --git a/CHALLENGE_2/sleepysound/lib/pages/settings_page.dart b/CHALLENGE_2/sleepysound/lib/pages/settings_page.dart new file mode 100644 index 0000000..06ce291 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/pages/settings_page.dart @@ -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), + ), + ], + ), + ), + ); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/pages/voting_page.dart b/CHALLENGE_2/sleepysound/lib/pages/voting_page.dart new file mode 100644 index 0000000..647e9fb --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/pages/voting_page.dart @@ -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 createState() => _VotingPageState(); +} + +class _VotingPageState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + List _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 _loadInitialQueue() async { + final queueService = Provider.of(context, listen: false); + await queueService.initializeQueue(); + } + + Future _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(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(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(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(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( + builder: (context, queueService, child) { + return Scaffold( + backgroundColor: const Color(0xFF121212), + body: SafeArea( + child: Column( + children: [ + // User Activity Status + Consumer( + 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(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( + 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( + 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', + ); + } + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/pages/voting_page_new.dart b/CHALLENGE_2/sleepysound/lib/pages/voting_page_new.dart new file mode 100644 index 0000000..427570a --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/pages/voting_page_new.dart @@ -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 createState() => _VotingPageState(); +} + +class _VotingPageState extends State { + final TextEditingController _searchController = TextEditingController(); + List _searchResults = []; + bool _isLoading = false; + String _statusMessage = ''; + + @override + void initState() { + super.initState(); + _loadInitialQueue(); + } + + Future _loadInitialQueue() async { + final queueService = Provider.of(context, listen: false); + await queueService.initializeQueue(); + } + + Future _searchSpotify(String query) async { + if (query.isEmpty) return; + + setState(() { + _isLoading = true; + _statusMessage = 'Searching for "$query"...'; + }); + + try { + final queueService = Provider.of(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(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( + 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(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, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/services/audio_service.dart b/CHALLENGE_2/sleepysound/lib/services/audio_service.dart new file mode 100644 index 0000000..f8f43e9 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/audio_service.dart @@ -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 _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 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 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 stop() async { + try { + await _audioPlayer.stop(); + } catch (e) { + print('Error stopping audio: $e'); + } + + _isPlaying = false; + _currentPosition = Duration.zero; + _currentTrack = null; + notifyListeners(); + } + + Future 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 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(); + } + } +} diff --git a/CHALLENGE_2/sleepysound/lib/services/genre_filter_service.dart b/CHALLENGE_2/sleepysound/lib/services/genre_filter_service.dart new file mode 100644 index 0000000..49025fb --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/genre_filter_service.dart @@ -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 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 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 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 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 filterSearchResults(List 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 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 = []; + 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(); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart b/CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart new file mode 100644 index 0000000..adcfc9a --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart @@ -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 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 _queue = []; + + // Recently played + final List _recentlyPlayed = []; + + // Getters + SpotifyTrack? get currentTrack => _audioService.currentTrack ?? _currentTrack; + bool get isPlaying => _audioService.isPlaying; + double get progress => _audioService.progress; + List get queue => List.unmodifiable(_queue); + List 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 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 togglePlayPause() async { + await _audioService.togglePlayPause(); + notifyListeners(); + } + + Future skipTrack() async { + await playNext(); + } + + Future 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 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> searchTracks(String query) async { + return await _spotifyService.searchTracks(query, limit: 20); + } + + // Get queue as JSON for display + List> get queueAsJson { + return _queue.map((item) => item.toJson()).toList(); + } + + // Get current track info for display + Map? 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, + }; + } +} diff --git a/CHALLENGE_2/sleepysound/lib/services/network_group_service.dart b/CHALLENGE_2/sleepysound/lib/services/network_group_service.dart new file mode 100644 index 0000000..ccdca0f --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/network_group_service.dart @@ -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 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 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 _networkUsers = {}; + late NetworkUser _currentUser; + MusicQueueService? _musicService; + + // Getters + bool get isConnectedToWifi => _isConnectedToWifi; + String get currentNetworkName => _currentNetworkName; + String get currentNetworkSSID => _currentNetworkSSID; + String get localIpAddress => _localIpAddress; + List get networkUsers => _networkUsers.values.toList(); + NetworkUser get currentUser => _currentUser; + int get onlineUsersCount => _networkUsers.values.where((u) => u.isOnline).length; + + NetworkGroupService() { + _initializeCurrentUser(); + _startNetworkMonitoring(); + // 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 _startNetworkMonitoring() async { + // Monitor connectivity changes + _connectivity.onConnectivityChanged.listen(_onConnectivityChanged); + + // Initial connectivity check + await _checkConnectivity(); + } + + Future _onConnectivityChanged(List results) async { + await _checkConnectivity(); + } + + Future _checkConnectivity() async { + final connectivityResult = await _connectivity.checkConnectivity(); + final wasConnected = _isConnectedToWifi; + + _isConnectedToWifi = connectivityResult.contains(ConnectivityResult.wifi); + + if (_isConnectedToWifi) { + await _getNetworkInfo(); + if (!wasConnected) { + await _startNetworkServices(); + } + } else { + if (wasConnected) { + await _stopNetworkServices(); + } + _clearNetworkInfo(); + } + + notifyListeners(); + } + + Future _getNetworkInfo() async { + try { + _currentNetworkSSID = await _networkInfo.getWifiName() ?? 'Unknown Network'; + _currentNetworkName = _currentNetworkSSID.replaceAll('"', ''); // Remove quotes + _localIpAddress = await _networkInfo.getWifiIP() ?? ''; + + // Calculate network subnet (assuming /24) + if (_localIpAddress.isNotEmpty) { + final parts = _localIpAddress.split('.'); + if (parts.length == 4) { + _networkSubnet = '${parts[0]}.${parts[1]}.${parts[2]}'; + } + } + + // Update current user's IP + _currentUser = NetworkUser( + id: _currentUser.id, + name: _currentUser.name, + ipAddress: _localIpAddress, + joinedAt: _currentUser.joinedAt, + votes: _currentUser.votes, + ); + } catch (e) { + if (kDebugMode) { + print('Error getting network info: $e'); + } + } + } + + void _clearNetworkInfo() { + _currentNetworkName = ''; + _currentNetworkSSID = ''; + _localIpAddress = ''; + _networkSubnet = ''; + _networkUsers.clear(); + } + + Future _startNetworkServices() async { + if (!_isConnectedToWifi || _localIpAddress.isEmpty) return; + + try { + // Start mDNS client for service discovery + _mdnsClient = MDnsClient(); + await _mdnsClient!.start(); + + // Start HTTP server for peer communication + await _startHttpServer(); + + // Start announcing our service + await _announceService(); + + // Start discovering other users + _startDiscovery(); + + // Start heartbeat for keeping users online + _startHeartbeat(); + + // Add ourselves to the users list + _networkUsers[_currentUser.id] = _currentUser; + + } catch (e) { + if (kDebugMode) { + print('Error starting network services: $e'); + } + } + } + + Future _stopNetworkServices() async { + // Stop timers + _heartbeatTimer?.cancel(); + _discoveryTimer?.cancel(); + + // Stop HTTP server + await _httpServer?.close(); + _httpServer = null; + + // Stop mDNS client + _mdnsClient?.stop(); + _mdnsClient = null; + + // Clear users + _networkUsers.clear(); + } + + Future _startHttpServer() async { + try { + _httpServer = await HttpServer.bind(InternetAddress.anyIPv4, _discoveryPort); + _httpServer!.listen((request) async { + await _handleHttpRequest(request); + }); + } catch (e) { + if (kDebugMode) { + print('Error starting HTTP server: $e'); + } + } + } + + Future _handleHttpRequest(HttpRequest request) async { + try { + final response = request.response; + response.headers.set('Content-Type', 'application/json'); + response.headers.set('Access-Control-Allow-Origin', '*'); + + if (request.method == 'GET' && request.uri.path == '/user') { + // 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 _announceService() async { + if (_mdnsClient == null || _localIpAddress.isEmpty) return; + + try { + // This would announce our service via mDNS + // In a real implementation, you'd use proper mDNS announcements + if (kDebugMode) { + print('Announcing service on $_localIpAddress:$_discoveryPort'); + } + } catch (e) { + if (kDebugMode) { + print('Error announcing service: $e'); + } + } + } + + void _startDiscovery() { + _discoveryTimer = Timer.periodic(const Duration(seconds: 15), (_) async { + await _discoverUsers(); + }); + + // Initial discovery + _discoverUsers(); + } + + Future _discoverUsers() async { + if (_networkSubnet.isEmpty) return; + + // Scan local network for other SleepySound users + final futures = []; + + for (int i = 1; i <= 254; i++) { + final ip = '$_networkSubnet.$i'; + if (ip != _localIpAddress) { + futures.add(_tryConnectToUser(ip)); + } + } + + // Wait for all connection attempts (with timeout) + await Future.wait(futures).timeout( + const Duration(seconds: 10), + onTimeout: () => [], + ); + } + + Future _tryConnectToUser(String ip) async { + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 2); + + final request = await client.getUrl(Uri.parse('http://$ip:$_discoveryPort/user')); + final response = await request.close().timeout(const Duration(seconds: 2)); + + if (response.statusCode == 200) { + final body = await utf8.decoder.bind(response).join(); + final userData = jsonDecode(body); + final user = NetworkUser.fromJson(userData); + + if (user.id != _currentUser.id) { + _networkUsers[user.id] = user; + notifyListeners(); + } + } + + client.close(); + } catch (e) { + // Ignore connection errors (expected for non-SleepySound devices) + } + } + + void _startHeartbeat() { + _heartbeatTimer = Timer.periodic(const Duration(seconds: _heartbeatInterval), (_) async { + await _sendHeartbeat(); + _cleanupOfflineUsers(); + }); + } + + Future _sendHeartbeat() async { + final heartbeatData = jsonEncode(_currentUser.toJson()); + + // Send heartbeat to all known users + final futures = _networkUsers.values + .where((user) => user.id != _currentUser.id && user.isOnline) + .map((user) => _sendHeartbeatToUser(user.ipAddress, heartbeatData)); + + await Future.wait(futures); + } + + Future _sendHeartbeatToUser(String ip, String data) async { + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 2); + + final request = await client.postUrl(Uri.parse('http://$ip:$_discoveryPort/heartbeat')); + request.headers.set('Content-Type', 'application/json'); + request.write(data); + + await request.close().timeout(const Duration(seconds: 2)); + client.close(); + } catch (e) { + // Mark user as potentially offline + final user = _networkUsers.values.firstWhere( + (u) => u.ipAddress == ip, + orElse: () => NetworkUser(id: '', name: '', ipAddress: '', joinedAt: DateTime.now()), + ); + if (user.id.isNotEmpty) { + user.isOnline = false; + notifyListeners(); + } + } + } + + void _cleanupOfflineUsers() { + final now = DateTime.now(); + final usersToRemove = []; + + for (final user in _networkUsers.values) { + if (user.id != _currentUser.id) { + final timeSinceLastSeen = now.difference(user.lastSeen).inSeconds; + if (timeSinceLastSeen > _heartbeatInterval * 3) { + user.isOnline = false; + // Remove users that have been offline for more than 5 minutes + if (timeSinceLastSeen > 300) { + usersToRemove.add(user.id); + } + } + } + } + + for (final userId in usersToRemove) { + _networkUsers.remove(userId); + } + + if (usersToRemove.isNotEmpty) { + notifyListeners(); + } + } + + Future _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 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 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(); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/services/spam_protection_service.dart b/CHALLENGE_2/sleepysound/lib/services/spam_protection_service.dart new file mode 100644 index 0000000..3eaeb49 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/spam_protection_service.dart @@ -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> _userVotes = {}; + final Map> _userSuggestions = {}; + final Map _lastVoteTime = {}; + final Map _lastSuggestionTime = {}; + final Map _consecutiveActions = {}; + + // Blocked users (temporary) + final Map _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 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 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> 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 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(); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/services/spotify_service.dart b/CHALLENGE_2/sleepysound/lib/services/spotify_service.dart new file mode 100644 index 0000000..330c27a --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/services/spotify_service.dart @@ -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 _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 _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> 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> 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 _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 _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)'; + } + } +} diff --git a/CHALLENGE_2/sleepysound/lib/widgets/network_demo_widget.dart b/CHALLENGE_2/sleepysound/lib/widgets/network_demo_widget.dart new file mode 100644 index 0000000..25a88c4 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/widgets/network_demo_widget.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import '../services/network_group_service.dart'; + +class NetworkDemoWidget extends StatefulWidget { + final NetworkGroupService networkService; + + const NetworkDemoWidget({ + super.key, + required this.networkService, + }); + + @override + State createState() => _NetworkDemoWidgetState(); +} + +class _NetworkDemoWidgetState extends State { + bool _isDemoMode = false; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF2D2D2D), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF6366F1), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.developer_mode, + color: Color(0xFF6366F1), + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Demo Mode', + style: TextStyle( + color: Color(0xFF6366F1), + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const Spacer(), + Switch( + value: _isDemoMode, + onChanged: (value) { + setState(() { + _isDemoMode = value; + }); + _toggleDemoMode(value); + }, + activeColor: const Color(0xFF6366F1), + ), + ], + ), + if (_isDemoMode) ...[ + const SizedBox(height: 12), + const Text( + 'Demo mode simulates network connectivity and adds sample users for testing.', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _addDemoUser, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1).withOpacity(0.2), + foregroundColor: const Color(0xFF6366F1), + elevation: 0, + ), + child: const Text('Add Demo User'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: _simulateVote, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF22C55E).withOpacity(0.2), + foregroundColor: const Color(0xFF22C55E), + elevation: 0, + ), + child: const Text('Simulate Vote'), + ), + ), + ], + ), + ], + ], + ), + ); + } + + void _toggleDemoMode(bool enabled) { + if (enabled) { + // Simulate network connection + widget.networkService.simulateNetworkConnection(); + _addInitialDemoUsers(); + } else { + // Clear demo users + widget.networkService.clearDemoUsers(); + } + } + + void _addInitialDemoUsers() { + final demoUsers = [ + {'name': 'Alex M.', 'votes': 5}, + {'name': 'Sarah K.', 'votes': 3}, + {'name': 'Marco R.', 'votes': 7}, + {'name': 'Lisa F.', 'votes': 2}, + ]; + + for (var user in demoUsers) { + widget.networkService.addDemoUser(user['name'] as String, user['votes'] as int); + } + } + + void _addDemoUser() { + final names = ['Tom B.', 'Emma W.', 'David L.', 'Anna K.', 'Mike R.', 'Julia S.']; + final randomName = names[DateTime.now().millisecond % names.length]; + final randomVotes = (DateTime.now().millisecond % 10) + 1; + + widget.networkService.addDemoUser(randomName, randomVotes); + } + + void _simulateVote() { + widget.networkService.updateUserVotes(widget.networkService.currentUser.votes + 1); + } +} diff --git a/CHALLENGE_2/sleepysound/lib/widgets/network_widgets.dart b/CHALLENGE_2/sleepysound/lib/widgets/network_widgets.dart new file mode 100644 index 0000000..ab3074e --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/widgets/network_widgets.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import '../services/network_group_service.dart'; + +class NetworkStatusCard extends StatelessWidget { + final NetworkGroupService networkService; + + const NetworkStatusCard({ + super.key, + required this.networkService, + }); + + @override + Widget build(BuildContext context) { + final isConnected = networkService.isConnectedToWifi; + final networkName = networkService.currentNetworkName; + final localIp = networkService.localIpAddress; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isConnected + ? const Color(0xFF22C55E).withOpacity(0.1) + : const Color(0xFFEF4444).withOpacity(0.1), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), + width: 1, + ), + ), + child: Column( + children: [ + Icon( + isConnected ? Icons.wifi : Icons.wifi_off, + size: 40, + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), + ), + const SizedBox(height: 10), + Text( + isConnected + ? '📶 Connected to $networkName' + : '❌ Not connected to WiFi', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isConnected + ? const Color(0xFF22C55E) + : const Color(0xFFEF4444), + ), + ), + const SizedBox(height: 5), + Text( + isConnected + ? 'You can now vote and suggest music with others on this network!' + : 'Please connect to WiFi to join the group session', + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + if (isConnected && localIp.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Your IP: $localIp', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ); + } +} + +class UserCard extends StatelessWidget { + final NetworkUser user; + final bool isCurrentUser; + + const UserCard({ + super.key, + required this.user, + this.isCurrentUser = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: isCurrentUser + ? const Color(0xFF6366F1).withOpacity(0.1) + : const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(12), + border: isCurrentUser + ? Border.all(color: const Color(0xFF6366F1), width: 1) + : null, + ), + child: Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: isCurrentUser + ? const Color(0xFF6366F1) + : const Color(0xFF4B5563), + child: Text( + user.name.substring(0, 1), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + if (user.isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF121212), width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + user.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + if (isCurrentUser) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF6366F1), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'You', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + 'Joined ${_formatDuration(DateTime.now().difference(user.joinedAt))} ago', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + if (!user.isOnline) ...[ + const Text( + ' • ', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + const Text( + 'Offline', + style: TextStyle( + color: Colors.red, + fontSize: 12, + ), + ), + ], + ], + ), + if (user.ipAddress.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'IP: ${user.ipAddress}', + style: const TextStyle( + color: Colors.grey, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${user.votes} votes', + style: const TextStyle( + color: Color(0xFF6366F1), + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + + String _formatDuration(Duration duration) { + if (duration.inMinutes < 1) { + return 'less than a minute'; + } else if (duration.inMinutes < 60) { + return '${duration.inMinutes} minute${duration.inMinutes == 1 ? '' : 's'}'; + } else { + final hours = duration.inHours; + final minutes = duration.inMinutes % 60; + if (minutes == 0) { + return '$hours hour${hours == 1 ? '' : 's'}'; + } else { + return '$hours hour${hours == 1 ? '' : 's'} $minutes minute${minutes == 1 ? '' : 's'}'; + } + } + } +} diff --git a/CHALLENGE_2/sleepysound/lib/widgets/user_activity_status.dart b/CHALLENGE_2/sleepysound/lib/widgets/user_activity_status.dart new file mode 100644 index 0000000..834c1d8 --- /dev/null +++ b/CHALLENGE_2/sleepysound/lib/widgets/user_activity_status.dart @@ -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( + 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( + suggestionsUsed > maxSuggestions * 0.9 ? Colors.red : Colors.orange, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/CHALLENGE_2/sleepysound/linux/flutter/generated_plugin_registrant.cc b/CHALLENGE_2/sleepysound/linux/flutter/generated_plugin_registrant.cc index e71a16d..cc10c4d 100644 --- a/CHALLENGE_2/sleepysound/linux/flutter/generated_plugin_registrant.cc +++ b/CHALLENGE_2/sleepysound/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,14 @@ #include "generated_plugin_registrant.h" +#include +#include 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); } diff --git a/CHALLENGE_2/sleepysound/linux/flutter/generated_plugins.cmake b/CHALLENGE_2/sleepysound/linux/flutter/generated_plugins.cmake index 2e1de87..8e2a190 100644 --- a/CHALLENGE_2/sleepysound/linux/flutter/generated_plugins.cmake +++ b/CHALLENGE_2/sleepysound/linux/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift b/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..a320557 100644 --- a/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/CHALLENGE_2/sleepysound/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,20 @@ import FlutterMacOS import Foundation +import audioplayers_darwin +import connectivity_plus +import network_info_plus +import path_provider_foundation +import shared_preferences_foundation +import url_launcher_macos +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/CHALLENGE_2/sleepysound/pubspec.lock b/CHALLENGE_2/sleepysound/pubspec.lock index d993b91..952bb96 100644 --- a/CHALLENGE_2/sleepysound/pubspec.lock +++ b/CHALLENGE_2/sleepysound/pubspec.lock @@ -1,6 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -9,6 +41,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: e653f162ddfcec1da2040ba2d8553fff1662b5c2a5c636f4c21a3b11bee497de + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" boolean_selector: dependency: transitive description: @@ -17,6 +105,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0b1b12a0a549605e5f04476031cd0bc91ead1d7c8e830773a18ee54179b3cb62" + url: "https://pub.dev" + source: hosted + version: "8.11.0" characters: dependency: transitive description: @@ -25,6 +177,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -33,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" collection: dependency: transitive description: @@ -41,6 +217,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "051849e2bd7c7b3bc5844ea0d096609ddc3a859890ec3a9ac4a65a2620cc1f99" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -49,6 +257,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" fake_async: dependency: transitive description: @@ -57,11 +281,43 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -75,6 +331,99 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: "direct main" + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -107,6 +456,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -131,6 +488,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + multicast_dns: + dependency: "direct main" + description: + name: multicast_dns + sha256: de72ada5c3db6fdd6ad4ae99452fe05fb403c4bb37c67ceb255ddd37d2b5b1eb + url: "https://pub.dev" + source: hosted + version: "0.3.3" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + network_info_plus: + dependency: "direct main" + description: + name: network_info_plus + sha256: "5bd4b86e28fed5ed4e6ac7764133c031dfb7d3f46aa2a81b46f55038aa78ecc0" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + network_info_plus_platform_interface: + dependency: transitive + description: + name: network_info_plus_platform_interface + sha256: "7e7496a8a9d8136859b8881affc613c4a21304afeb6c324bcefc4bd0aff6b94b" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -139,11 +552,211 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4f81479fe5194a622cdd1713fe1ecb683a6e6c85cd8cec8e2e35ee5ab3fdf2a1" + url: "https://pub.dev" + source: hosted + version: "1.3.6" source_span: dependency: transitive description: @@ -152,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -168,6 +789,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -176,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" + url: "https://pub.dev" + source: hosted + version: "3.3.1" term_glyph: dependency: transitive description: @@ -192,6 +829,94 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: @@ -208,6 +933,102 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "9573ad97890d199ac3ab32399aa33a5412163b37feb573eb5b0a76b35e9ffe41" + url: "https://pub.dev" + source: hosted + version: "4.8.2" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "71523b9048cf510cfa1fd4e0a3fa5e476a66e0884d5df51d59d5023dba237107" + url: "https://pub.dev" + source: hosted + version: "3.22.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.7.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.27.0" diff --git a/CHALLENGE_2/sleepysound/pubspec.yaml b/CHALLENGE_2/sleepysound/pubspec.yaml index c054c82..fef8b3e 100644 --- a/CHALLENGE_2/sleepysound/pubspec.yaml +++ b/CHALLENGE_2/sleepysound/pubspec.yaml @@ -34,6 +34,34 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + + # HTTP requests + http: ^1.1.0 + + # JSON handling + json_annotation: ^4.8.1 + + # URL launcher for Spotify authentication + url_launcher: ^6.2.1 + + # Shared preferences for storing tokens + shared_preferences: ^2.2.2 + + # Web view for authentication + webview_flutter: ^4.4.2 + + # State management + provider: ^6.1.1 + + # Network and WiFi connectivity + connectivity_plus: ^6.0.5 + network_info_plus: ^5.0.3 + + # Local network discovery + multicast_dns: ^0.3.2+4 + + # Audio playback + audioplayers: ^6.0.0 dev_dependencies: flutter_test: @@ -45,6 +73,13 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 + + # JSON serialization + json_serializable: ^6.7.1 + build_runner: ^2.4.7 + + # App icon generator + flutter_launcher_icons: ^0.13.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -58,7 +93,9 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: + assets: + - assets/audio/ + - assets/icons/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg @@ -87,3 +124,13 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + +flutter_launcher_icons: + android: true + ios: true + web: + generate: true + image_path: "assets/icons/app_icon.png" + adaptive_icon_background: "#121212" + adaptive_icon_foreground: "assets/icons/app_icon_foreground.png" + remove_alpha_ios: true diff --git a/CHALLENGE_2/sleepysound/web/favicon.png b/CHALLENGE_2/sleepysound/web/favicon.png index 8aaa46a..4cf62ff 100644 Binary files a/CHALLENGE_2/sleepysound/web/favicon.png and b/CHALLENGE_2/sleepysound/web/favicon.png differ diff --git a/CHALLENGE_2/sleepysound/web/icons/Icon-192.png b/CHALLENGE_2/sleepysound/web/icons/Icon-192.png index b749bfe..f087383 100644 Binary files a/CHALLENGE_2/sleepysound/web/icons/Icon-192.png and b/CHALLENGE_2/sleepysound/web/icons/Icon-192.png differ diff --git a/CHALLENGE_2/sleepysound/web/icons/Icon-512.png b/CHALLENGE_2/sleepysound/web/icons/Icon-512.png index 88cfd48..03214a1 100644 Binary files a/CHALLENGE_2/sleepysound/web/icons/Icon-512.png and b/CHALLENGE_2/sleepysound/web/icons/Icon-512.png differ diff --git a/CHALLENGE_2/sleepysound/web/icons/Icon-maskable-192.png b/CHALLENGE_2/sleepysound/web/icons/Icon-maskable-192.png index eb9b4d7..f087383 100644 Binary files a/CHALLENGE_2/sleepysound/web/icons/Icon-maskable-192.png and b/CHALLENGE_2/sleepysound/web/icons/Icon-maskable-192.png differ diff --git a/CHALLENGE_2/sleepysound/web/icons/Icon-maskable-512.png b/CHALLENGE_2/sleepysound/web/icons/Icon-maskable-512.png index d69c566..03214a1 100644 Binary files a/CHALLENGE_2/sleepysound/web/icons/Icon-maskable-512.png and b/CHALLENGE_2/sleepysound/web/icons/Icon-maskable-512.png differ diff --git a/CHALLENGE_2/sleepysound/web/manifest.json b/CHALLENGE_2/sleepysound/web/manifest.json index 59317b1..e9218d8 100644 --- a/CHALLENGE_2/sleepysound/web/manifest.json +++ b/CHALLENGE_2/sleepysound/web/manifest.json @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file diff --git a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc index 8b6d468..d836cf4 100644 --- a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc +++ b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,15 @@ #include "generated_plugin_registrant.h" +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake index b93c4c3..5ac8838 100644 --- a/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake +++ b/CHALLENGE_2/sleepysound/windows/flutter/generated_plugins.cmake @@ -3,6 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows + connectivity_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST