diff --git a/.DS_Store b/.DS_Store index df7e4de..4a50b42 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 19769e9..722d5e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ .vscode -CHALLENGE_2/sleepysound/lib/services/SPOTIFY_SECRET.dart 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 diff --git a/PlantDashboard/.DS_Store b/PlantDashboard/.DS_Store index 87bc276..b81a9ba 100644 Binary files a/PlantDashboard/.DS_Store and b/PlantDashboard/.DS_Store differ diff --git a/PlantDashboard/.cache.sqlite b/PlantDashboard/.cache.sqlite deleted file mode 100644 index c710cb9..0000000 Binary files a/PlantDashboard/.cache.sqlite and /dev/null differ diff --git a/PlantDashboard/__pycache__/data_handler.cpython-311.pyc b/PlantDashboard/__pycache__/data_handler.cpython-311.pyc deleted file mode 100644 index 9cfdff1..0000000 Binary files a/PlantDashboard/__pycache__/data_handler.cpython-311.pyc and /dev/null differ diff --git a/PlantDashboard/__pycache__/main_dashboard.cpython-311.pyc b/PlantDashboard/__pycache__/main_dashboard.cpython-311.pyc deleted file mode 100644 index b26f726..0000000 Binary files a/PlantDashboard/__pycache__/main_dashboard.cpython-311.pyc and /dev/null differ diff --git a/PlantDashboard/__pycache__/main_dashboard.cpython-313.pyc b/PlantDashboard/__pycache__/main_dashboard.cpython-313.pyc index 0a06932..9566346 100644 Binary files a/PlantDashboard/__pycache__/main_dashboard.cpython-313.pyc and b/PlantDashboard/__pycache__/main_dashboard.cpython-313.pyc differ diff --git a/PlantDashboard/__pycache__/plant_meteo.cpython-311.pyc b/PlantDashboard/__pycache__/plant_meteo.cpython-311.pyc deleted file mode 100644 index e28f24b..0000000 Binary files a/PlantDashboard/__pycache__/plant_meteo.cpython-311.pyc and /dev/null differ diff --git a/PlantDashboard/__pycache__/plant_meteo.cpython-313.pyc b/PlantDashboard/__pycache__/plant_meteo.cpython-313.pyc deleted file mode 100644 index e9601b5..0000000 Binary files a/PlantDashboard/__pycache__/plant_meteo.cpython-313.pyc and /dev/null differ diff --git a/PlantDashboard/__pycache__/plant_model.cpython-311.pyc b/PlantDashboard/__pycache__/plant_model.cpython-311.pyc deleted file mode 100644 index 2a054e1..0000000 Binary files a/PlantDashboard/__pycache__/plant_model.cpython-311.pyc and /dev/null differ diff --git a/PlantDashboard/main_dashboard.py b/PlantDashboard/main_dashboard.py index 323d442..fb446ff 100644 --- a/PlantDashboard/main_dashboard.py +++ b/PlantDashboard/main_dashboard.py @@ -6,8 +6,7 @@ import os from datetime import datetime, date from plant_model import PlantGrowthModel from data_handler import DataHandler -from tkcalendar import DateEntry, Calendar -from plant_meteo import HappyMeteo +from tkcalendar import DateEntry class PlantGrowthDashboard: def __init__(self, root): @@ -16,11 +15,7 @@ class PlantGrowthDashboard: self.root.geometry("1000x800") # More square dimensions self.root.configure(bg='#f0f0f0') - image = Image.open("public/logoTransparent.png") - - desired_size = (128, 128) - image = image.resize(desired_size, Image.Resampling.LANCZOS) - + image = Image.open("public/transparentLogo.png") # Convert to PhotoImage icon = ImageTk.PhotoImage(image) # Set as window icon @@ -29,12 +24,9 @@ class PlantGrowthDashboard: # Initialize components self.plant_model = PlantGrowthModel() self.data_handler = DataHandler() - self.happyMeteo = HappyMeteo() # Variables - fixed plant type self.current_plant = "tomato" # Fixed plant type - self.counter = 0 - self.filenames = ["basilico.jpg", "pomodoro.png"] self.ambient_mode = tk.StringVar(value="controlled") self.baseline_image_path = None @@ -71,8 +63,8 @@ class PlantGrowthDashboard: # Configure grid weights for square layout self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) - main_frame.columnconfigure(0, weight=3) - main_frame.columnconfigure(1, weight=1) # Center panel wider + main_frame.columnconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=2) # Center panel wider main_frame.columnconfigure(2, weight=1) main_frame.rowconfigure(1, weight=1) @@ -151,8 +143,6 @@ class PlantGrowthDashboard: command=lambda x, p=param: self.on_param_change(p)) scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 4)) - setattr(self, f"{param}_scale", scale) - # Value label value_label = ttk.Label(param_frame, text=f"{self.env_params[param].get():.1f}", width=5, font=('Arial', 8)) value_label.pack(side=tk.RIGHT) @@ -173,33 +163,28 @@ class PlantGrowthDashboard: spacer.grid(row=row, column=0, columnspan=2, pady=10) # Add this after the parameters section - ttk.Label(control_frame, text="Final date of growth (choose a date):", + ttk.Label(control_frame, text="Final date of growth:", font=('Arial', 9, 'bold')).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=(10, 4)) row += 1 # Compact date entry with calendar popup - # To get the selected dat simply call self.calendar.get_date() - # self.date_entry - self.calendar = Calendar(control_frame, selectmode='day', - year=2025, month=8, day=1, - font=('Arial', 8)) - self.calendar.grid(row=row, column=0, columnspan=2, pady=2) + # To get the selected dat simply call self.date_entry.get_date() + self.date_entry = DateEntry(control_frame, + width=12, + background='darkblue', + foreground='white', + borderwidth=2, + font=('Arial', 8), + date_pattern='dd/mm/yyyy', + state='readonly', # Add this + cursor='hand2') # Add this + self.date_entry.grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=2) row += 1 control_frame.columnconfigure(0, weight=1) control_frame.columnconfigure(1, weight=1) - - def disable_parameter(self, param): - scale = getattr(self, f"{param}_scale", None) - if scale: - scale.configure(state='disabled') - - def enable_parameter(self, param): - scale = getattr(self, f"{param}_scale", None) - if scale: - scale.configure(state='normal') - + def setup_visualization_panel(self, parent): viz_frame = ttk.LabelFrame(parent, text="Plant Visualization", padding="6") viz_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=3) @@ -246,7 +231,7 @@ class PlantGrowthDashboard: info_frame.pack(fill=tk.X, pady=(8, 0)) self.plant_info_text = tk.Text(info_frame, height=8, width=35, wrap=tk.WORD, - font=('Arial', 8), bg="#000000", fg="white") + font=('Arial', 8), bg="#000000") self.plant_info_text.pack(fill=tk.BOTH, expand=True) # Submit button @@ -279,7 +264,9 @@ class PlantGrowthDashboard: """Update the evolution tab with an image from file or show fallback text""" if filename and os.path.exists(filename): try: - print(filename) + # Load and display the image + from PIL import Image, ImageTk + # Open and resize image if needed pil_image = Image.open(filename) # Optional: resize to fit the display area @@ -338,18 +325,14 @@ class PlantGrowthDashboard: if current_mode == "controlled": print("Switched to Controlled mode") # Enable all parameter controls - self.enable_parameter("humidity") - self.enable_parameter("brightness") - self.enable_parameter("temperature") - # No need to call the meteo api elif current_mode == "open": print("Switched to Open mode") # Disable most parameter controls (temp, humidity, light) - self.disable_parameter("humidity") - self.disable_parameter("brightness") - self.disable_parameter("temperature") + + # Call the meteo api to retrieve all the parameters, set variable meteo_values to retrieve when submitiing all + # Inside the retrieving of all the data check if the mode is one, select which data to use def on_param_change(self, param): value = self.env_params[param].get() @@ -371,7 +354,7 @@ class PlantGrowthDashboard: self.ambient_mode.set("controlled") - self.calendar.selection_set(date.today()) + self.date_entry.set_date(date.today()) def update_parameter_label(self, param, value): """Update the value label for a specific parameter""" @@ -400,59 +383,14 @@ class PlantGrowthDashboard: ) if file_path: self.baseline_image_path = file_path - self.update_initial_plant_display() - + def submit_plant_data(self): """Submit plant information and photo""" try: - start_date = datetime.now().date() - - # Fix: Convert calendar date string to date object - calendar_date = self.calendar.get_date() - if isinstance(calendar_date, str): - # Parse the string date (assuming format like "2025-08-02" or "02/08/2025") - try: - if '/' in calendar_date: - # Handle DD/MM/YYYY format - end_date = datetime.strptime(calendar_date, '%d/%m/%Y').date() - else: - # Handle YYYY-MM-DD format - end_date = datetime.strptime(calendar_date, '%Y-%m-%d').date() - except ValueError: - # Fallback: try different formats - for fmt in ['%d/%m/%Y', '%Y-%m-%d', '%m/%d/%Y']: - try: - end_date = datetime.strptime(calendar_date, fmt).date() - break - except ValueError: - continue - else: - # If all formats fail, use today - end_date = datetime.now().date() - else: - # It's already a date object - end_date = calendar_date - - time_lapse = end_date - start_date - days_difference = time_lapse.days - + # Get current parameters params = {param: var.get() for param, var in self.env_params.items()} params['plant_type'] = self.current_plant params['ambient_mode'] = self.ambient_mode.get() - - current_mode = self.ambient_mode.get() - happy_data = None # Initialize to None instead of 0 - - if current_mode == "open": - happy_data = self.happyMeteo.openMeteoCall(days_difference) - - # Filter out excluded parameters for open mode - excluded_params = {"humidity", "temperature", "brightness"} - params = {param: var.get() for param, var in self.env_params.items() - if param not in excluded_params} - # Re-add the metadata - params['plant_type'] = self.current_plant - params['ambient_mode'] = self.ambient_mode.get() # Create submission data submission_data = { @@ -460,65 +398,42 @@ class PlantGrowthDashboard: 'parameters': params, 'baseline_image_path': self.baseline_image_path, 'plant_info': self.plant_info_text.get(1.0, tk.END), - 'start_date': start_date.isoformat(), # Fixed: was 'start date' (space) - 'end_date': end_date.isoformat(), - 'time_lapse_days': days_difference # Added time lapse info + 'start date': datetime.now().date().isoformat(), + 'end_date': self.date_entry.get_date().isoformat() } - if current_mode == "open" and happy_data is not None: - submission_data['meteoForecast'] = happy_data - - # Clear plant_info_text + #Remove plant_info_text self.plant_info_text.delete(1.0, tk.END) - # Save submission data data_dir = "../data" os.makedirs(data_dir, exist_ok=True) current_date = datetime.now().strftime('%Y%m%d') filename = f"{current_date}-{current_date}.txt" filepath = os.path.join(data_dir, filename) - with open(filepath, 'w') as f: json.dump(submission_data, f, indent=4) # Here call the bot pipeline to store results on files in plant_data # results are in the form of (text, image) - results = None + results = "come bot please" - if results is not None: # Fixed: changed != None to is not None - text = getattr(results, 'text', None) - image_filename = getattr(results, 'image', None) - else: - text = "<<<----Here at your left you can see the results of the growth of the plant!" - image_filename = self.filenames[self.counter] # Fixed: removed leading slash - self.counter += 1 - - # Create plant_data directory + text = getattr(results, 'text', None) + image_filename = getattr(results, 'image', None) images_dir = "./plant_data" - os.makedirs(images_dir, exist_ok=True) # Fixed: was data_dir instead of images_dir - - image_path = f"public/{image_filename.split('/')[-1]}" - - # Update UI with results + os.makedirs(data_dir, exist_ok=True) + image_path = os.path.join(images_dir, image_filename) self.updating_evolution_and_forecasts(text, image_path) # Here update the informations in the last box from plant_data/texts - # TODO: Implement reading from plant_data/texts - # Here update the informations in growth evolution from plant_data/images - # TODO: Implement reading from plant_data/images - - # Show success message with better formatting - messagebox.showinfo("Submission Successful", - "Submission successful!\n\n" - "Go to Growth Evolution tab to see the results.") + # Here update the informations in growth evolution from plant_data/images + #Calling a small advertment to notify the user that the image has been generated + messagebox.showinfo("Submission successful, go to growth evolution to see the results") print(f"Submission data saved to: {filepath}") - except Exception as e: messagebox.showerror("Submission Error", f"Error submitting data: {str(e)}") - print(f"Error details: {e}") # For debugging - + def updating_evolution_and_forecasts(self, text, image_path): self.results_text.config(state='normal') # Enable editing self.results_text.delete(1.0, tk.END) # Clear existing content diff --git a/PlantDashboard/plant_meteo.py b/PlantDashboard/plant_meteo.py deleted file mode 100644 index fbb42a5..0000000 --- a/PlantDashboard/plant_meteo.py +++ /dev/null @@ -1,80 +0,0 @@ -import openmeteo_requests - -import pandas as pd -import requests_cache -from retry_requests import retry -import geocoder - - -class HappyMeteo: - def __init__(self): - # Setup the Open-Meteo API client with cache and retry on error - cache_session = requests_cache.CachedSession('.cache', expire_after = 3600) - retry_session = retry(cache_session, retries = 5, backoff_factor = 0.2) - self.openmeteo = openmeteo_requests.Client(session = retry_session) - - def get_current_location(self): - """Get current location using IP geolocation""" - try: - g = geocoder.ip('me') - if g.ok: - latitude = g.latlng[0] - longitude = g.latlng[1] - print(f"Latitude: {latitude}") - print(f"Longitude: {longitude}") - print(f"Address: {g.address}") - return latitude, longitude - else: - print("Could not determine location") - return None, None - except Exception as e: - print(f"Error getting location: {e}") - return None, None - -def openMeteoCall(self, timeLapse): - lat, lon = self.get_current_location() - - # Make sure all required weather variables are listed here - # The order of variables in hourly or daily is important to assign them correctly below - url = "https://api.open-meteo.com/v1/forecast" - params = { - "latitude": lat, - "longitude": lon, - "daily": ["weather_code", "temperature_2m_mean", "rain_sum", "showers_sum", "precipitation_sum", "daylight_duration", "relative_humidity_2m_mean"], - "timezone": "auto", - "forecast_days": timeLapse - } - responses = self.openmeteo.weather_api(url, params=params) - - # Process first location. Add a for-loop for multiple locations or weather models - response = responses[0] - - # Process daily data. The order of variables needs to be the same as requested. - daily = response.Daily() - daily_weather_code = daily.Variables(0).ValuesAsNumpy() - daily_temperature_2m_mean = daily.Variables(1).ValuesAsNumpy() - daily_rain_sum = daily.Variables(2).ValuesAsNumpy() - daily_showers_sum = daily.Variables(3).ValuesAsNumpy() - daily_precipitation_sum = daily.Variables(4).ValuesAsNumpy() - daily_daylight_duration = daily.Variables(5).ValuesAsNumpy() - daily_relative_humidity_2m_mean = daily.Variables(6).ValuesAsNumpy() - - # Return comprehensive data structure - return { - "daily_data": { - "weather_code": daily_weather_code.tolist(), - "temperature_2m_mean": daily_temperature_2m_mean.tolist(), - "rain_sum": daily_rain_sum.tolist(), - "showers_sum": daily_showers_sum.tolist(), - "precipitation_sum": daily_precipitation_sum.tolist(), - "daylight_duration": daily_daylight_duration.tolist(), - "relative_humidity_2m_mean": daily_relative_humidity_2m_mean.tolist() - }, - "summary": { - "avg_temperature": float(daily_temperature_2m_mean.mean()), - "total_precipitation": float(daily_precipitation_sum.sum()), - "avg_humidity": float(daily_relative_humidity_2m_mean.mean()), - "total_daylight_hours": float(daily_daylight_duration.sum() / 3600) # Convert seconds to hours - } - } - diff --git a/PlantDashboard/public/.DS_Store b/PlantDashboard/public/.DS_Store index 0ba74f5..61bd1cc 100644 Binary files a/PlantDashboard/public/.DS_Store and b/PlantDashboard/public/.DS_Store differ diff --git a/PlantDashboard/public/logo.jpg b/PlantDashboard/public/logo.jpg new file mode 100644 index 0000000..332b356 Binary files /dev/null and b/PlantDashboard/public/logo.jpg differ diff --git a/PlantDashboard/public/logo.png b/PlantDashboard/public/logo.png deleted file mode 100644 index 7517897..0000000 Binary files a/PlantDashboard/public/logo.png and /dev/null differ diff --git a/PlantDashboard/public/logoTransparent.png b/PlantDashboard/public/logoTransparent.png deleted file mode 100644 index 6586849..0000000 Binary files a/PlantDashboard/public/logoTransparent.png and /dev/null differ diff --git a/PlantDashboard/public/pomodoro.png b/PlantDashboard/public/pomodoro.png deleted file mode 100644 index 1d9dfc0..0000000 Binary files a/PlantDashboard/public/pomodoro.png and /dev/null differ diff --git a/PlantDashboard/public/transparentLogo.png b/PlantDashboard/public/transparentLogo.png new file mode 100644 index 0000000..bd04425 Binary files /dev/null and b/PlantDashboard/public/transparentLogo.png differ diff --git a/PlantDashboard/requirements.txt b/PlantDashboard/requirements.txt index 4e360cb..90880d9 100644 --- a/PlantDashboard/requirements.txt +++ b/PlantDashboard/requirements.txt @@ -30,10 +30,6 @@ memory-profiler>=0.60.0 tkcalendar -openmeteo-requests -requests-cache -retry-requests -geocoder # Note: tkinter comes pre-installed with most Python distributions # If tkinter is not available, install it using your system package manager: # Ubuntu/Debian: sudo apt-get install python3-tk diff --git a/README.md b/README.md index a264376..e43b993 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,110 @@ -\# WeGrow +# WeGrow +> *Hackathon Project - [NOI Hackathon] [2025]* +## ๐Ÿš€ Overview -> \*Hackathon Project - \[NOI Hackathon] \[2025]\* +Hi everyone! We are WeGrow, +## ๐ŸŽฏ Problem Statement +Describe the challenge or problem your team is addressing in this hackathon. -\## ๐Ÿš€ Overview +## ๐Ÿ’ก Solution +Explain your approach and how your solution addresses the problem statement. +## โœจ Features -Hi everyone! We are WeGrow, we are working on a project involving a chatbot which will predict the growth and health of plants. +- [ ] Feature 1 +- [ ] Feature 2 +- [ ] Feature 3 +- [ ] Feature 4 +## ๐Ÿ› ๏ธ Tech Stack +**Frontend:** -\## ๐ŸŽฏ Problem Statement +- Technology 1 +- Technology 2 +**Backend:** +- Technology 1 +- Technology 2 -The main challenge was to find the right LLM to work in a short time. In particular, to find LLM that parsed text to images and including also meteo api + gps integration to shape the answer of the LLM. We have tested our solution on the basilicum plant, and predicting its growth depending upon different weather condition. There is also the possibility to predict the growth indoor, and not only outside. In that case the user decides the parameters (as humidity, temperature, pressure and so on with a GUI). On the other hand , if the user wants to predict the growth of the plant outside, the LLM will individuate the current weather for the wished period of the user. +**Other Tools:** +- Tool 1 +- Tool 2 +## ๐Ÿ—๏ธ Architecture -As a problem , we faced an issue to describe the final picture obtained through the various LLM, using Llava as the last one. We ran into GPU exaustation problem, withouth a fully understanding of such problem. - - - -\## ๐Ÿ’ก Solution - - - -To address the problems of the correct answering we had to reading various articles on the correct open source LLM to use. We used : - - - -\- StableDiffusionInstructPix2PixPipeline.from\_pretrained("timbrooks/instruct-pix2pix") : to have the images + text as input and give an image as output. - - - -To install it I have installed ollama for windows from here : - -https://ollama.com/download - - - -\- Llava to generate from the final image a text (unsuccessful). To install it we did: - - - -  git clone https://github.com/haotian-liu/LLaVA.git - -  cd LLaVA - -  pip install -e . - - - -\- Successfully tried to train a model by crawling images from google of basil and tomaetos and training it, to try to optimize the ouput of StableDiffusionInstructPix2PixPipeline.from\_pretrained("timbrooks/instruct-pix2pix"), without actually many changes. - - - -\## โœจ Features - - - -\- \[ ] Interactive dashboard sliders - -\- \[ ] Possibility to decide the environment condititions (open vs. controlled (e.g. laboratory)) - -\- \[ ] Augmented precision of the data trough the open meteo api and the gps integration - -\- \[ ] Possibility with the selection of the local environment to run the application completely local, transforming the application in to a lightweight and robust friend - - - -\## ๐Ÿ› ๏ธ Tech Stack - - - -\*\*Frontend:\*\* - - - -\- Python with tkinter - - - -\*\*Backend:\*\* - - - -\- python scripts - - - -\*\*Other Tools:\*\* - - - -\- Open meteo api - -\- All the llms to predict the growth explained later - - - -\### Installation - - - -1\. Clone the repository +```text +[Add architecture diagram or description here] +``` +## ๐Ÿš€ Using the application +### Prerequisites ```bash +# List any prerequisites here +# e.g., Node.js 18+, Python 3.9+ +``` +### Installation + +1. Clone the repository + +```bash git clone https://github.com/your-username/NOIProject.git - cd NOIProject - ``` - - -1\. Install dependencies - - +1. Install dependencies ```bash - -\# Go into PlantDashboard and create your venv (e.g. PlantEnv) - -python3 -m venv PlantEnv - +# Add installation commands here +# e.g., npm install, pip install -r requirements.txt ``` - - -1\. Installing the application - - +1. Set up environment variables ```bash - -\#Always in the PlantDashBoard - -pip install -r requirements.txt - +# Copy example environment file +cp .env.example .env +# Edit .env with your configuration ``` +1. Run the application +```bash +# Add run commands here +# e.g., npm start, python app.py +``` -\## ๐Ÿš€ Using the application +## ๐Ÿ“ธ Screenshots +Add screenshots of your application here +## ๐ŸŽฅ Demo -After following the installation steps, you can use our application by simply activating the venv with "source PlantEnv/bin/activate" and running "python launcher.py" from inside the PlantDashBoard folder +Add link to demo video or live deployment +## ๐Ÿง‘โ€๐Ÿ’ป Team +Meet our amazing team of 4: + +| Name | Role | GitHub | LinkedIn | +|------|------|---------|----------| +| Member 1 | Role | [@username](https://github.com/username) | [LinkedIn](https://linkedin.com/in/username) | +| Member 2 | Role | [@username](https://github.com/username) | [LinkedIn](https://linkedin.com/in/username) | +| Member 3 | Role | [@username](https://github.com/username) | [LinkedIn](https://linkedin.com/in/username) | +| Member 4 | Role | [@username](https://github.com/username) | [LinkedIn](https://linkedin.com/in/username) | + +## ๐Ÿ“„ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +--- diff --git a/__pycache__/script.cpython-311.pyc b/__pycache__/script.cpython-311.pyc deleted file mode 100644 index 36213a5..0000000 Binary files a/__pycache__/script.cpython-311.pyc and /dev/null differ diff --git a/data/20250802-20250802.txt b/data/20250802-20250802.txt deleted file mode 100644 index f7aeea1..0000000 --- a/data/20250802-20250802.txt +++ /dev/null @@ -1,20 +0,0 @@ -{ - "timestamp": "2025-08-02T10:54:55.312319", - "parameters": { - "temperature": 22.0, - "humidity": 65.0, - "soil_acidity": 6.5, - "pressure": 1013.25, - "brightness": 30.0, - "nutrients": 75.0, - "water": 80.0, - "co2": 850.0, - "plant_type": "tomato", - "ambient_mode": "controlled" - }, - "baseline_image_path": "/Users/giusber2005/Desktop/workspace/repositories/projects/team-2/PlantDashboard/public/pomodoro.png", - "plant_info": "\n", - "start_date": "2025-08-02", - "end_date": "2025-08-02", - "time_lapse_days": 0 -} \ No newline at end of file diff --git a/forDashboardIntegration.py b/forDashboardIntegration.py deleted file mode 100644 index 0e07774..0000000 --- a/forDashboardIntegration.py +++ /dev/null @@ -1,42 +0,0 @@ -from datetime import datetime -from script import PlantPredictor - -def dashboard_plant_prediction(image_path, start_date, end_date, additional_notes=""): - """ - Simple function for dashboard integration - """ - try: - # Calculate days - start_dt = datetime.strptime(start_date, "%Y-%m-%d") - end_dt = datetime.strptime(end_date, "%Y-%m-%d") - days = (end_dt - start_dt).days - - if days <= 0: - return {"success": False, "error": "Invalid date range"} - - # Create predictor and run - predictor = PlantPredictor() - result = predictor.dashboard_plant_prediction(image_path, days, additional_notes) - - if result: - return {"success": True, "result": result} - else: - return {"success": False, "error": "No result"} - - except Exception as e: - return {"success": False, "error": str(e)} - - -# Test -if __name__ == "__main__": - result = dashboard_plant_prediction( - "./basilico.jpg", - "2024-08-01", - "2024-08-08", - "Test plant" - ) - - if result["success"]: - print(" SUCCESS!") - else: - print(f" ERROR: {result['error']}") \ No newline at end of file diff --git a/path.txt b/path.txt index a3b4c99..ad13395 100644 --- a/path.txt +++ b/path.txt @@ -1,5 +1,18 @@ ROADMAP - -format the data that will came to the user + + -differences between the three modes: + Open mode: + -the temp, humidity, light, water parameters are setted by the meteo api, the rest is setted by the user + -all the parameters are controlled by the user + SemiControlled mode: + -the user choose how to set the parameters + -all parameters free + + Controlled mode: + -all the values are set by the user + -the user choose which parameters are free and which are controlled by the meteo api + + -make the calendar widget working -final updates of README.md, hackathon page, forgejo, github page, small design adjustments. diff --git a/script.py b/script.py deleted file mode 100644 index 3c28508..0000000 --- a/script.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import datetime -import sys -import os - -class PlantPredictor: - def dashboard_plant_prediction( - image_path: str, - start_date: str, - end_date: str, - additional_notes: str = "" - ) -> dict: - try: - # Calcola giorni - start_dt = datetime.strptime(start_date, "%Y-%m-%d") - end_dt = datetime.strptime(end_date, "%Y-%m-%d") - days = (end_dt - start_dt).days - if days <= 0: - return {"success": False, "error": "End date must be after start date", "days": days} - - # Log - print(f"Dashboard prediction request: {start_date} to {end_date} ({days} days) image={image_path}") - if additional_notes: - print(f"Notes: {additional_notes}") - - # Inizializza il predictor e chiama il metodo - predictor = PlantPredictor() - result = predictor.predict_plant_growth(image_path, days, additional_notes) - - # Unwrap risultato tuple - if isinstance(result, tuple) and len(result) == 5: - _img, conditions, weather_df, plant_type, plant_health = result - return { - "success": True, - "plant_analysis": {"plant_type": plant_type, "plant_health": plant_health}, - "weather_conditions": conditions, - "weather_data_shape": weather_df.shape, - "parameters_used": {"start_date": start_date, "end_date": end_date, "days": days, "notes": additional_notes, "image": image_path}, - "prediction_summary": { - "temperature_range": f"{conditions['avg_temp_min']}โ€“{conditions['avg_temp_max']}ยฐC", - "total_rain": f"{conditions['total_rain']}mm", - "sunshine_hours": f"{conditions['total_sunshine_hours']}h" - } - } - else: - return {"success": False, "error": "Invalid result from PlantPredictor", "result": result} - - except ValueError as e: - return {"success": False, "error": f"Date format error: {e}"} - except Exception as e: - return {"success": False, "error": f"Unexpected error: {e}"} - -# Esempio di test -if __name__ == '__main__': - res = dashboard_plant_prediction( - image_path='./basilico.jpg', - start_date='2024-08-01', - end_date='2024-08-08', - additional_notes='Indoor day 3' - ) - print(res) diff --git a/test3/.cache.sqlite b/test2/.cache.sqlite similarity index 93% rename from test3/.cache.sqlite rename to test2/.cache.sqlite index e10f120..57753f7 100644 Binary files a/test3/.cache.sqlite and b/test2/.cache.sqlite differ diff --git a/test3/README.md b/test2/README.md similarity index 100% rename from test3/README.md rename to test2/README.md diff --git a/PlantDashboard/public/basilico.jpg b/test2/foto/basilico.jpg similarity index 100% rename from PlantDashboard/public/basilico.jpg rename to test2/foto/basilico.jpg diff --git a/test2/predicted_plant_ultra_hq.jpg b/test2/predicted_plant_ultra_hq.jpg new file mode 100644 index 0000000..d5e3c16 Binary files /dev/null and b/test2/predicted_plant_ultra_hq.jpg differ diff --git a/test2/predicted_plant_ultra_hq_comparison.jpg b/test2/predicted_plant_ultra_hq_comparison.jpg new file mode 100644 index 0000000..4c6310c Binary files /dev/null and b/test2/predicted_plant_ultra_hq_comparison.jpg differ diff --git a/test3/requirements.txt b/test2/requirements.txt similarity index 100% rename from test3/requirements.txt rename to test2/requirements.txt diff --git a/PlantDashboard/script.py b/test2/script.py similarity index 100% rename from PlantDashboard/script.py rename to test2/script.py diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/image.png b/test2_with_training_tuned_with_basil_and_tomaetos/image.png deleted file mode 100644 index f177566..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/image.png and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/.cache.sqlite b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/.cache.sqlite index e3f70ee..dbf3e33 100644 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/.cache.sqlite and b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/.cache.sqlite differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/__pycache__/model.cpython-311.pyc b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/__pycache__/model.cpython-311.pyc index bfdc1a9..34eb76a 100644 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/__pycache__/model.cpython-311.pyc and b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/__pycache__/model.cpython-311.pyc differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basil.png b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basil.png deleted file mode 100644 index dd235ab..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basil.png and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilico2.jpg b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilico.jpg similarity index 100% rename from test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilico2.jpg rename to test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilico.jpg diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilico.png b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilico.png deleted file mode 100644 index f177566..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilico.png and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilnew.png b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilnew.png deleted file mode 100644 index 4b62a17..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilnew.png and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilnew_llava_description.txt b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilnew_llava_description.txt deleted file mode 100644 index e8b5e1e..0000000 --- a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/basilnew_llava_description.txt +++ /dev/null @@ -1 +0,0 @@ -Descrizione non disponibile. \ No newline at end of file diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth.jpg b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth.jpg index b0c25bf..a9d99ae 100644 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth.jpg and b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth.jpg differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth.png b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth.png deleted file mode 100644 index e15c0d7..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth.png and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth_llava_description.txt b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth_llava_description.txt deleted file mode 100644 index e8b5e1e..0000000 --- a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/predicted_plant_growth_llava_description.txt +++ /dev/null @@ -1 +0,0 @@ -Descrizione non disponibile. \ No newline at end of file diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/script.py b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/script.py index 029e60f..0afe49e 100644 --- a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/script.py +++ b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/script.py @@ -1,4 +1,5 @@ import io +import os import openmeteo_requests import pandas as pd import requests_cache @@ -12,8 +13,6 @@ from torchvision import transforms from model import PlantClassifier # personalizzalo secondo il tuo file import geocoder import sys -from accelerate import init_empty_weights, load_checkpoint_and_dispatch -import os print(sys.stdout.encoding) # Check what encoding your console is using # Force UTF-8 encoding for the entire script @@ -27,7 +26,6 @@ class PlantPredictor: cache_session = requests_cache.CachedSession('.cache', expire_after=3600) retry_session = retry(cache_session, retries=5, backoff_factor=0.2) self.openmeteo = openmeteo_requests.Client(session=retry_session) - self.image_model = None self.trained_model = None self.class_labels = ["basil", "tomato"] # oppure caricali dinamicamente @@ -229,7 +227,7 @@ class PlantPredictor: # Weather + growth prompt logic (come da tua versione) temp_avg = (plant_conditions['avg_temp_max'] + plant_conditions['avg_temp_min']) / 2 - if plant_type == "basil" or plant_type == "tomato" or ("herb" in plant_type): + if plant_type == "basilico" or ("herb" in plant_type): if temp_avg > 25: temp_effect = "warm weather promoting vigorous basil growth with larger, aromatic leaves and bushier structure" elif temp_avg < 15: @@ -327,51 +325,7 @@ class PlantPredictor: except Exception as e: print(f"โš ๏ธ Error in health assessment: {e}") return "unknown health" - - def describe_image_with_llava(self, image_pil, prompt=None): - """Use LLaVA-Next on CPU to generate a description of the plant image.""" - try: - from transformers import LlavaNextForConditionalGeneration, AutoProcessor - - if not hasattr(self, "llava_model"): - print("๐Ÿ”„ Caricamento modello LLaVA-Next su CPUโ€ฆ") - model_id = "llava-hf/llava-v1.6-mistral-7b-hf" - - # 1) Load the processor - self.llava_processor = AutoProcessor.from_pretrained(model_id) - - # 2) Load the model in half-precision, low memory mode - self.llava_model = LlavaNextForConditionalGeneration.from_pretrained( - model_id, - torch_dtype=torch.float16, - low_cpu_mem_usage=True - ).to("cpu") - print("โœ… LLaVA-Next caricato su CPU correttamente") - - # Free GPU memory if you still have SD components loaded - if torch.cuda.is_available(): - del self.image_model - torch.cuda.empty_cache() - - # 3) Prepend the token so the processor knows where the image belongs - llava_prompt = " " + (prompt or "Describe the plant growth and condition in this image.") - - # 4) Build inputs explicitly - inputs = self.llava_processor( - images=image_pil, - text=llava_prompt, - return_tensors="pt" - ).to("cpu") - - # 5) Generate - output = self.llava_model.generate(**inputs, max_new_tokens=150) - description = self.llava_processor.decode(output[0], skip_special_tokens=True) - return description - - except Exception as e: - print(f"โš ๏ธ Errore durante la descrizione con LLaVA-Next: {e}") - return "Descrizione non disponibile." - + def transform_plant_image(self, image_path, prompt): """STEP 4: Generate new image based on analyzed prompt""" @@ -459,37 +413,8 @@ class PlantPredictor: return None if result_image: - # Salva lโ€™immagine predetta result_image.save(output_path) print(f"Plant growth prediction saved to: {output_path}") - - # โ€”โ€”โ€”โ€”โ€”โ€” Qui inizia il codice per il .txt โ€”โ€”โ€”โ€”โ€”โ€” - # Componi la descrizione - description = ( - f"{plant_type.capitalize()} prevista dopo {plant_conditions['days_analyzed']} giorni:\n" - f"- Temperatura: {plant_conditions['avg_temp_min']}โ€“{plant_conditions['avg_temp_max']} ยฐC\n" - f"- Pioggia: {plant_conditions['total_rain']} mm\n" - f"- Sole: {plant_conditions['total_sunshine_hours']} h\n" - f"- UV max: {plant_conditions['max_uv_index']}\n" - f"- Range termico giornaliero: {plant_conditions['temp_range']} ยฐC\n" - f"Salute stimata: {plant_health}." - ) - - # STEP 4.5: Descrizione immagine predetta con LLaVA-Next - try: - llava_description = self.describe_image_with_llava(result_image, prompt) - print("๐Ÿง  Descrizione generata da LLaVA-Next:") - print(llava_description) - - # Salva descrizione in file .txt separato - llava_txt_path = os.path.splitext(output_path)[0] + "_llava_description.txt" - with open(llava_txt_path, "w", encoding="utf-8") as f: - f.write(llava_description) - print(f"๐Ÿ“„ Descrizione visiva salvata in: {llava_txt_path}") - except Exception as e: - print(f"โš ๏ธ LLaVA-Next non ha potuto descrivere lโ€™immagine: {e}") - - return result_image, plant_conditions, weather_df, plant_type, plant_health else: print("Failed to transform image") @@ -507,10 +432,10 @@ if __name__ == "__main__": # Predict plant growth # Replace 'your_plant_image.jpg' with actual image path result = predictor.predict_plant_growth( - image_path="./tomato.jpg", + image_path="./basilico.jpg", lat=latitude, lon=longitude, - output_path="./tomato_new2.jpg", + output_path="./predicted_plant_growth.jpg", days=7 ) diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/script2.py b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/script2.py deleted file mode 100644 index 26679ff..0000000 --- a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/script2.py +++ /dev/null @@ -1,651 +0,0 @@ -import io -import openmeteo_requests -import pandas as pd -import requests_cache -from retry_requests import retry -from datetime import datetime, timedelta -from PIL import Image -import torch -from diffusers import StableDiffusionInstructPix2PixPipeline -import numpy as np -from torchvision import transforms -from model import PlantClassifier # personalizzalo secondo il tuo file -import geocoder -import sys -from accelerate import init_empty_weights, load_checkpoint_and_dispatch -import os -print(sys.stdout.encoding) # Check what encoding your console is using - -# Force UTF-8 encoding for the entire script -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') -sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') - -class PlantPredictor: - def __init__(self): - """Initialize the plant prediction pipeline with Open-Meteo client""" - # Setup the Open-Meteo API client with cache and retry on error - cache_session = requests_cache.CachedSession('.cache', expire_after=3600) - retry_session = retry(cache_session, retries=5, backoff_factor=0.2) - self.openmeteo = openmeteo_requests.Client(session=retry_session) - - self.image_model = None - self.trained_model = None - self.class_labels = ["basil", "tomato"] # oppure caricali dinamicamente - self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - - def load_trained_model(self, model_path="./models/basil_tomato_classifier.pth"): - if not os.path.exists(model_path): - print("โš ๏ธ Trained model not found!") - return - - try: - model = PlantClassifier(num_classes=2) - - # Load checkpoint with proper device mapping - checkpoint = torch.load(model_path, map_location=self.device) - - # Handle different checkpoint formats - if 'model_state_dict' in checkpoint: - state_dict = checkpoint['model_state_dict'] - else: - # If the checkpoint is just the state dict - state_dict = checkpoint - - # Fix key mismatches between training and inference models - # The saved model has keys like "features.*" but current model expects "backbone.features.*" - corrected_state_dict = {} - for key, value in state_dict.items(): - if key.startswith('features.'): - # Add "backbone." prefix to features - new_key = 'backbone.' + key - corrected_state_dict[new_key] = value - elif key.startswith('classifier.'): - # Add "backbone." prefix to classifier - new_key = 'backbone.' + key - corrected_state_dict[new_key] = value - else: - # Keep other keys as they are - corrected_state_dict[key] = value - - # Load the corrected state dict - model.load_state_dict(corrected_state_dict, strict=False) - - model.to(self.device) - model.eval() - self.trained_model = model - print(f"โœ… Model loaded successfully on {self.device}") - - except Exception as e: - print(f"โš ๏ธ Error loading trained model: {e}") - self.trained_model = None - - - def get_current_location(self): - try: - g = geocoder.ip('me') - if g.ok: - print(f"๐Ÿ“ Location detected: {g.city}, {g.country}") - print(f"๐Ÿ“ Coordinates: {g.latlng[0]:.4f}, {g.latlng[1]:.4f}") - return g.latlng[0], g.latlng[1] - else: - print("โš ๏ธ Could not detect location, using default (Milan)") - except Exception as e: - print(f"โš ๏ธ Location detection failed: {e}, using default (Milan)") - - # default Milan coords if failed - return 45.4642, 9.1900 - - - def load_image_model(self): - """Load the image transformation model""" - print("Loading Stable Diffusion model...") - self.image_model = StableDiffusionInstructPix2PixPipeline.from_pretrained( - "timbrooks/instruct-pix2pix", - torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32 - ) - if torch.cuda.is_available(): - self.image_model = self.image_model.to("cuda") - print("Model loaded successfully!") - - def get_weather_forecast(self, lat, lon, days=7): - """Get weather forecast from Open-Meteo API using official client""" - - start_date = datetime.now().strftime("%Y-%m-%d") - end_date = (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") - - url = "https://api.open-meteo.com/v1/forecast" - params = { - "latitude": lat, - "longitude": lon, - "daily": [ - "temperature_2m_max", - "temperature_2m_min", - "precipitation_sum", - "rain_sum", - "uv_index_max", - "sunshine_duration" - ], - "start_date": start_date, - "end_date": end_date, - "timezone": "auto" - } - - try: - responses = self.openmeteo.weather_api(url, params=params) - response = responses[0] # Process first location - - print(f"Coordinates: {response.Latitude()}ยฐN {response.Longitude()}ยฐE") - print(f"Elevation: {response.Elevation()} m asl") - print(f"Timezone: UTC{response.UtcOffsetSeconds()//3600:+d}") - - # Process daily data - daily = response.Daily() - - # Extract data as numpy arrays (much faster!) - daily_data = { - "date": pd.date_range( - start=pd.to_datetime(daily.Time(), unit="s", utc=True), - end=pd.to_datetime(daily.TimeEnd(), unit="s", utc=True), - freq=pd.Timedelta(seconds=daily.Interval()), - inclusive="left" - ), - "temperature_2m_max": daily.Variables(0).ValuesAsNumpy(), - "temperature_2m_min": daily.Variables(1).ValuesAsNumpy(), - "precipitation_sum": daily.Variables(2).ValuesAsNumpy(), - "rain_sum": daily.Variables(3).ValuesAsNumpy(), - "uv_index_max": daily.Variables(4).ValuesAsNumpy(), - "sunshine_duration": daily.Variables(5).ValuesAsNumpy() - } - - # Create DataFrame for easy analysis - daily_dataframe = pd.DataFrame(data=daily_data) - - return daily_dataframe, response - - except Exception as e: - print(f"Error fetching weather data: {e}") - return None, None - - def analyze_weather_for_plants(self, weather_df): - """Analyze weather data and create plant-specific metrics""" - - if weather_df is None or weather_df.empty: - return None - - # Handle NaN values by filling with 0 or mean - weather_df = weather_df.fillna(0) - - # Calculate plant-relevant metrics using pandas (more efficient) - plant_conditions = { - "avg_temp_max": round(weather_df['temperature_2m_max'].mean(), 1), - "avg_temp_min": round(weather_df['temperature_2m_min'].mean(), 1), - "total_precipitation": round(weather_df['precipitation_sum'].sum(), 1), - "total_rain": round(weather_df['rain_sum'].sum(), 1), - "total_sunshine_hours": round(weather_df['sunshine_duration'].sum() / 3600, 1), # Convert to hours - "max_uv_index": round(weather_df['uv_index_max'].max(), 1), - "days_analyzed": len(weather_df), - "temp_range": round(weather_df['temperature_2m_max'].max() - weather_df['temperature_2m_min'].min(), 1) - } - - return plant_conditions - - CLASS_NAMES = {0: "basil", 1: "tomato"} # Adatta se usi nomi diversi - - def create_transformation_prompt(self, image_path, plant_conditions): - if not plant_conditions: - return "Show this plant after one week of growth", "generic plant", "unknown health" - - plant_type = "generic plant" - plant_health = "unknown health" - - try: - if not os.path.exists(image_path): - raise FileNotFoundError(f"Image file not found at {image_path}") - with Image.open(image_path) as img: - image = img.convert("RGB") - width, height = image.size - - try: - plant_type = self.detect_plant_type(image) - except Exception as e: - print(f"โš ๏ธ Plant type detection failed: {e}") - plant_type = "generic plant" - - try: - plant_health = self.assess_plant_health(image) - except Exception as e: - print(f"โš ๏ธ Health assessment failed: {e}") - plant_health = "unknown health" - - print(f"๐Ÿ“ธ Image Analysis:") - print(f" Plant type detected: {plant_type}") - print(f" Current health: {plant_health}") - print(f" Image size: {width}x{height}") - except Exception as e: - print(f"โš ๏ธ Warning: Could not analyze image: {str(e)}") - plant_type = "generic plant" - plant_health = "healthy" - - # Weather + growth prompt logic (come da tua versione) - temp_avg = (plant_conditions['avg_temp_max'] + plant_conditions['avg_temp_min']) / 2 - - if plant_type == "basil" or plant_type == "tomato" or ("herb" in plant_type): - if temp_avg > 25: - temp_effect = "warm weather promoting vigorous basil growth with larger, aromatic leaves and bushier structure" - elif temp_avg < 15: - temp_effect = "cool weather slowing basil growth with smaller, less vibrant leaves" - else: - temp_effect = "optimal temperature for basil supporting steady growth with healthy green foliage" - else: - if temp_avg > 25: - temp_effect = "warm weather promoting vigorous growth with larger, darker green leaves" - elif temp_avg < 10: - temp_effect = "cool weather slowing growth with smaller, pale leaves" - else: - temp_effect = "moderate temperature supporting steady growth with healthy green foliage" - - if plant_conditions['total_rain'] > 20: - water_effect = "abundant rainfall keeping leaves lush, turgid and deep green" - elif plant_conditions['total_rain'] < 5: - water_effect = "dry conditions causing slight leaf wilting and browning at edges" - else: - water_effect = "adequate moisture maintaining crisp, healthy leaf appearance" - - if plant_conditions['total_sunshine_hours'] > 50: - sun_effect = "plenty of sunlight encouraging dense, compact foliage growth" - elif plant_conditions['total_sunshine_hours'] < 20: - sun_effect = "limited sunlight causing elongated stems and sparse leaf growth" - else: - sun_effect = "moderate sunlight supporting balanced, proportional growth" - - if plant_conditions['max_uv_index'] > 7: - uv_effect = "high UV causing slight leaf thickening and waxy appearance" - else: - uv_effect = "moderate UV maintaining normal leaf texture" - - prompt = ( - f"Transform this {plant_type} showing realistic growth after {plant_conditions['days_analyzed']} days. " - f"Current state: {plant_health}. Apply these weather effects: {temp_effect}, {water_effect}, {sun_effect}, and {uv_effect}. " - f"Show natural changes in leaf size, color saturation, stem thickness, and overall plant structure while maintaining the original composition and lighting. " - f"Weather summary: {plant_conditions['avg_temp_min']}-{plant_conditions['avg_temp_max']}ยฐC, " - f"{plant_conditions['total_rain']}mm rain, {plant_conditions['total_sunshine_hours']}h sun" - ) - - return prompt, plant_type, plant_health - - def detect_plant_type(self, image): - """Use trained model to classify the plant type""" - if self.trained_model is None: - self.load_trained_model() - - if self.trained_model is None: - print("โš ๏ธ Trained model not available, using fallback rule.") - return "generic plant" - - try: - transform = transforms.Compose([ - transforms.Resize((224, 224)), # usa la stessa dimensione del training - transforms.ToTensor(), - transforms.Normalize([0.485, 0.456, 0.406], # mean/std di ImageNet o dataset tuo - [0.229, 0.224, 0.225]) - ]) - - input_tensor = transform(image).unsqueeze(0).to(self.device) - - with torch.no_grad(): - output = self.trained_model(input_tensor) - _, predicted = torch.max(output, 1) - predicted_class = self.class_labels[predicted.item()] - - # Get confidence score - probabilities = torch.nn.functional.softmax(output, dim=1) - confidence = probabilities[0][predicted].item() - - print(f"๐ŸŒฑ Plant classification: {predicted_class} (confidence: {confidence:.2f})") - return predicted_class - - except Exception as e: - print(f"โš ๏ธ Error in plant type detection: {e}") - return "generic plant" - - - def cleanup_gpu_memory(self): - """Clean up GPU memory and move models appropriately""" - if torch.cuda.is_available(): - # Move Stable Diffusion model to CPU if LLaVA is being used - if hasattr(self, 'image_model') and self.image_model is not None: - print("๐Ÿ’พ Moving Stable Diffusion to CPU to free GPU memory...") - self.image_model = self.image_model.to("cpu") - - torch.cuda.empty_cache() - torch.cuda.synchronize() - - # Print memory stats - allocated = torch.cuda.memory_allocated() / 1024**3 - cached = torch.cuda.memory_reserved() / 1024**3 - print(f"๐Ÿ“Š GPU Memory: {allocated:.1f}GB allocated, {cached:.1f}GB cached") - - def assess_plant_health(self, image): - """Assess basic plant health from image""" - try: - img_array = np.array(image) - - # Analyze brightness and color vibrancy - brightness = np.mean(img_array) - green_channel = np.mean(img_array[:,:,1]) - - if brightness > 150 and green_channel > 120: - return "healthy and vibrant" - elif brightness > 100 and green_channel > 80: - return "moderately healthy" - else: - return "showing some stress" - except Exception as e: - print(f"โš ๏ธ Error in health assessment: {e}") - return "unknown health" - - def describe_image_with_llava(self, image_pil, prompt=None): - """Use LLaVA-Next to generate a description of the plant image with proper device handling.""" - try: - from transformers import LlavaNextForConditionalGeneration, LlavaNextProcessor - import torch - - if not hasattr(self, "llava_model"): - print("๐Ÿ”„ Loading LLaVA-Next model...") - model_id = "llava-hf/llava-v1.6-mistral-7b-hf" - - # Use the correct processor for LLaVA-Next - self.llava_processor = LlavaNextProcessor.from_pretrained(model_id) - - # Determine optimal device configuration - if torch.cuda.is_available(): - # Check available GPU memory - gpu_memory = torch.cuda.get_device_properties(0).total_memory / 1024**3 # GB - print(f"๐Ÿ“Š Available GPU memory: {gpu_memory:.1f} GB") - - if gpu_memory >= 12: # High memory GPU - device_map = "auto" - torch_dtype = torch.float16 - print("๐Ÿš€ Using GPU with auto device mapping") - else: # Lower memory GPU - use CPU offloading - device_map = {"": "cpu"} - torch_dtype = torch.float32 - print("๐Ÿ’พ Using CPU due to limited GPU memory") - else: - device_map = {"": "cpu"} - torch_dtype = torch.float32 - print("๐Ÿ–ฅ๏ธ Using CPU (no GPU available)") - - # Load model with explicit device mapping - self.llava_model = LlavaNextForConditionalGeneration.from_pretrained( - model_id, - torch_dtype=torch_dtype, - low_cpu_mem_usage=True, - device_map=device_map, - offload_folder="./offload_cache", # Explicit offload directory - offload_state_dict=True if device_map != "auto" else False - ) - - # Ensure model is in eval mode - self.llava_model.eval() - print("โœ… LLaVA-Next loaded successfully") - - # Clear CUDA cache before inference - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - # Prepare the conversation format that LLaVA-Next expects - conversation = [ - { - "role": "user", - "content": [ - {"type": "image"}, - {"type": "text", "text": prompt or "Describe this plant's current condition, growth stage, health indicators, leaf characteristics, and any visible signs of stress or vitality. Focus on botanical details."} - ] - } - ] - - # Apply chat template and process inputs - prompt_text = self.llava_processor.apply_chat_template(conversation, add_generation_prompt=True) - - # Process inputs properly - inputs = self.llava_processor( - images=image_pil, - text=prompt_text, - return_tensors="pt" - ) - - # Handle device placement more carefully - target_device = "cpu" # Default to CPU for stability - if hasattr(self.llava_model, 'device'): - target_device = self.llava_model.device - elif hasattr(self.llava_model, 'hf_device_map'): - # Get the device of the first layer - for module_name, device in self.llava_model.hf_device_map.items(): - if device != 'disk': - target_device = device - break - - print(f"๐ŸŽฏ Moving inputs to device: {target_device}") - inputs = {k: v.to(target_device) if isinstance(v, torch.Tensor) else v for k, v in inputs.items()} - - # Generate with proper parameters and error handling - with torch.no_grad(): - try: - output = self.llava_model.generate( - **inputs, - max_new_tokens=150, # Reduced for stability - do_sample=False, # Use greedy decoding for consistency - temperature=None, # Not used with do_sample=False - top_p=None, # Not used with do_sample=False - pad_token_id=self.llava_processor.tokenizer.eos_token_id, - use_cache=True, - repetition_penalty=1.1 - ) - except RuntimeError as e: - if "out of memory" in str(e).lower(): - print("โš ๏ธ GPU OOM, retrying with CPU...") - # Move everything to CPU and retry - inputs = {k: v.cpu() if isinstance(v, torch.Tensor) else v for k, v in inputs.items()} - if hasattr(self.llava_model, 'cpu'): - self.llava_model = self.llava_model.cpu() - output = self.llava_model.generate( - **inputs, - max_new_tokens=150, - do_sample=False, - pad_token_id=self.llava_processor.tokenizer.eos_token_id - ) - else: - raise e - - # Decode only the new tokens (exclude input tokens) - input_length = inputs["input_ids"].shape[1] - generated_tokens = output[0][input_length:] - description = self.llava_processor.tokenizer.decode(generated_tokens, skip_special_tokens=True) - - # Clean up cache after generation - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - return description.strip() - - except ImportError as e: - print(f"โš ๏ธ LLaVA-Next dependencies not available: {e}") - return "Visual description not available - missing dependencies." - except Exception as e: - print(f"โš ๏ธ Error during LLaVA-Next description: {e}") - print(f"๐Ÿ” Error details: {type(e).__name__}: {str(e)}") - return f"Visual description failed: {str(e)}" - - def transform_plant_image(self, image_path, prompt): - """STEP 4: Generate new image based on analyzed prompt""" - - if self.image_model is None: - self.load_image_model() - - try: - # Load and prepare image - image = Image.open(image_path).convert("RGB") - - # Resize if too large (for memory efficiency) - if max(image.size) > 1024: - image.thumbnail((1024, 1024), Image.Resampling.LANCZOS) - - print(f" STEP 4: Generating transformed image...") - print(f" Using prompt: {prompt}") - - # Transform image - result = self.image_model( - prompt, - image=image, - num_inference_steps=70, - image_guidance_scale=1.5, - guidance_scale=7.5 - ).images[0] - - return result - - except Exception as e: - print(f"Error transforming image: {e}") - return None - - @staticmethod - def safe_print(text): - try: - print(text) - except UnicodeEncodeError: - # Fallback for systems with limited encoding support - print(text.encode('ascii', errors='replace').decode('ascii')) - - def predict_plant_growth(self, image_path, lat=None, lon=None, output_path="./predicted_plant.jpg", days=7): - """Complete pipeline: weather + image transformation""" - - # Auto-detect location if not provided - if lat is None or lon is None: - print(" Auto-detecting location...") - lat, lon = self.get_current_location() - - print(f" Starting plant prediction for coordinates: {lat:.4f}, {lon:.4f}") - print(f" Analyzing {days} days of weather data...") - - # Step 1: Get weather data using official Open-Meteo client - print("Fetching weather data with caching and retry...") - weather_df, response_info = self.get_weather_forecast(lat, lon, days) - - if weather_df is None: - print("Failed to get weather data") - return None - - print(f"Weather data retrieved for {len(weather_df)} days") - print("\nWeather Overview:") - print(weather_df[['date', 'temperature_2m_max', 'temperature_2m_min', 'precipitation_sum', 'sunshine_duration']].head()) - - # Step 2: Analyze weather for plants - plant_conditions = self.analyze_weather_for_plants(weather_df) - print(f"\nPlant-specific weather analysis: {plant_conditions}") - - # Step 3: Analyze image + weather to create intelligent prompt - print("\n STEP 3: Analyzing image and creating transformation prompt...") - try: - prompt, plant_type, plant_health = self.create_transformation_prompt(image_path, plant_conditions) - self.safe_print(f" Plant identified as: {plant_type}") - self.safe_print(f" Current health: {plant_health}") - self.safe_print(f" Generated transformation prompt: {prompt}") - except Exception as e: - print(f" Error in Step 3: {e}") - return None - - # Step 4: Generate transformed image - print("\nSTEP 4: Generating prediction image...") - try: - result_image = self.transform_plant_image(image_path, prompt) - except Exception as e: - print(f" Error in Step 4: {e}") - return None - - if result_image: - # Save the predicted image - result_image.save(output_path) - print(f"Plant growth prediction saved to: {output_path}") - - # Compose the basic description - description = ( - f"{plant_type.capitalize()} predicted after {plant_conditions['days_analyzed']} days:\n" - f"- Temperature: {plant_conditions['avg_temp_min']}โ€“{plant_conditions['avg_temp_max']} ยฐC\n" - f"- Rain: {plant_conditions['total_rain']} mm\n" - f"- Sunshine: {plant_conditions['total_sunshine_hours']} h\n" - f"- UV max: {plant_conditions['max_uv_index']}\n" - f"- Daily temperature range: {plant_conditions['temp_range']} ยฐC\n" - f"Estimated health: {plant_health}." - ) - - # STEP 4.5: Enhanced visual description with LLaVA-Next - try: - print("\n๐Ÿง  STEP 4.5: Generating detailed visual analysis...") - - # Clean up GPU memory before loading LLaVA - self.cleanup_gpu_memory() - - llava_description = self.describe_image_with_llava( - result_image, - f"Analyze this {plant_type} plant prediction image. Describe the visible growth changes, leaf development, overall health indicators, and how the plant appears to have responded to the weather conditions: {plant_conditions['avg_temp_min']}-{plant_conditions['avg_temp_max']}ยฐC, {plant_conditions['total_rain']}mm rain, {plant_conditions['total_sunshine_hours']}h sun over {plant_conditions['days_analyzed']} days." - ) - - print("๐Ÿง  AI Visual Analysis:") - print(llava_description) - - # Save comprehensive description - complete_description = f"{description}\n\nAI Visual Analysis:\n{llava_description}" - - description_txt_path = os.path.splitext(output_path)[0] + "_analysis.txt" - with open(description_txt_path, "w", encoding="utf-8") as f: - f.write(complete_description) - print(f"๐Ÿ“„ Complete analysis saved to: {description_txt_path}") - - except Exception as e: - print(f"โš ๏ธ Visual analysis failed: {e}") - # Still save basic description - basic_txt_path = os.path.splitext(output_path)[0] + "_basic_info.txt" - with open(basic_txt_path, "w", encoding="utf-8") as f: - f.write(description) - print(f"๐Ÿ“„ Basic info saved to: {basic_txt_path}") - - return result_image, plant_conditions, weather_df, plant_type, plant_health - else: - print("Failed to transform image") - return None - -# Example usage -if __name__ == "__main__": - # Initialize predictor - predictor = PlantPredictor() - - # Example coordinates (Milan, Italy) - latitude = 45.4642 - longitude = 9.1900 - - # Predict plant growth - # Replace 'your_plant_image.jpg' with actual image path - result = predictor.predict_plant_growth( - image_path="./basilico.jpg", - lat=latitude, - lon=longitude, - output_path="./basilico_new2.jpg", - days=7 - ) - - if result: - image, conditions, weather_data, plant_type, plant_health = result - print("\n" + "="*50) - print(" PLANT PREDICTION COMPLETED SUCCESSFULLY!") - print("="*50) - print(f" Plant type: {plant_type}") - print(f" Plant health: {plant_health}") - print(f" Weather conditions: {conditions}") - print(f" Data points: {weather_data.shape}") - print(f" Temperature: {conditions['avg_temp_min']}ยฐC to {conditions['avg_temp_max']}ยฐC") - print(f" Total rain: {conditions['total_rain']}mm") - print(f" Sunshine: {conditions['total_sunshine_hours']}h") - else: - print("Plant prediction failed.") - diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato.jpg b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato.jpg deleted file mode 100644 index 95b1993..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato.jpg and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato.png b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato.png deleted file mode 100644 index 2ad0add..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato.png and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new.png b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new.png deleted file mode 100644 index 3821c8e..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new.png and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new2.jpg b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new2.jpg deleted file mode 100644 index 3a623df..0000000 Binary files a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new2.jpg and /dev/null differ diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new2_llava_description.txt b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new2_llava_description.txt deleted file mode 100644 index e8b5e1e..0000000 --- a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new2_llava_description.txt +++ /dev/null @@ -1 +0,0 @@ -Descrizione non disponibile. \ No newline at end of file diff --git a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new_llava_description.txt b/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new_llava_description.txt deleted file mode 100644 index e8b5e1e..0000000 --- a/test2_with_training_tuned_with_basil_and_tomaetos/scripts/tomato_new_llava_description.txt +++ /dev/null @@ -1 +0,0 @@ -Descrizione non disponibile. \ No newline at end of file diff --git a/test3/foto/basilico-OLD.jpg b/test3/foto/basilico-OLD.jpg deleted file mode 100644 index d84b39d..0000000 Binary files a/test3/foto/basilico-OLD.jpg and /dev/null differ diff --git a/test3/foto/basilico1 originale.png b/test3/foto/basilico1 originale.png deleted file mode 100644 index dd235ab..0000000 Binary files a/test3/foto/basilico1 originale.png and /dev/null differ diff --git a/test3/foto/basilico2 originale.png b/test3/foto/basilico2 originale.png deleted file mode 100644 index f177566..0000000 Binary files a/test3/foto/basilico2 originale.png and /dev/null differ diff --git a/test3/script.py b/test3/script.py deleted file mode 100644 index d7ace64..0000000 --- a/test3/script.py +++ /dev/null @@ -1,534 +0,0 @@ -import openmeteo_requests -import pandas as pd -import requests_cache -from retry_requests import retry -from datetime import datetime, timedelta -from PIL import Image -import torch -from diffusers import StableDiffusionInstructPix2PixPipeline -import numpy as np -import geocoder - -class PlantPredictor: - def __init__(self): - """Initialize the plant prediction pipeline with Open-Meteo client""" - # Setup the Open-Meteo API client with cache and retry on error - cache_session = requests_cache.CachedSession('.cache', expire_after=3600) - retry_session = retry(cache_session, retries=5, backoff_factor=0.2) - self.openmeteo = openmeteo_requests.Client(session=retry_session) - - self.image_model = None - - def get_current_location(self): - """Get current location using IP geolocation""" - try: - g = geocoder.ip('me') - if g.ok: - print(f"๐Ÿ“ Location detected: {g.city}, {g.country}") - print(f"๐Ÿ“ Coordinates: {g.latlng[0]:.4f}, {g.latlng[1]:.4f}") - return g.latlng[0], g.latlng[1] # lat, lon - else: - print("โš ๏ธ Could not detect location, using default (Milan)") - self.image_model = None - except Exception as e: - print(f"โš ๏ธ Location detection failed: {e}, using default (Milan)") - - self.image_model = None - - def load_image_model(self): - """Load the image transformation model with high-quality settings""" - print("๐Ÿ”„ Loading Stable Diffusion model with high-quality settings...") - - # Check if CUDA is available and print GPU info - if torch.cuda.is_available(): - gpu_name = torch.cuda.get_device_name(0) - gpu_memory = torch.cuda.get_device_properties(0).total_memory / 1024**3 - print(f"๐Ÿš€ GPU: {gpu_name} ({gpu_memory:.1f} GB)") - - self.image_model = StableDiffusionInstructPix2PixPipeline.from_pretrained( - "timbrooks/instruct-pix2pix", - torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32, - use_safetensors=True, - safety_checker=None, - requires_safety_checker=False - ) - - if torch.cuda.is_available(): - self.image_model = self.image_model.to("cuda") - - # Enable memory efficient attention for better quality - try: - self.image_model.enable_xformers_memory_efficient_attention() - print("โœ… XFormers memory efficient attention enabled") - except: - print("โš ๏ธ XFormers not available, using standard attention") - - # Enable VAE slicing for higher resolution support - self.image_model.enable_vae_slicing() - print("โœ… VAE slicing enabled for high-res support") - - # Enable attention slicing for memory efficiency - self.image_model.enable_attention_slicing(1) - print("โœ… Attention slicing enabled") - - print("โœ… High-quality model loaded successfully!") - - def get_weather_forecast(self, lat, lon, days=7): - """Get weather forecast from Open-Meteo API using official client""" - - start_date = datetime.now().strftime("%Y-%m-%d") - end_date = (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") - - url = "https://api.open-meteo.com/v1/forecast" - params = { - "latitude": lat, - "longitude": lon, - "daily": [ - "temperature_2m_max", - "temperature_2m_min", - "precipitation_sum", - "rain_sum", - "uv_index_max", - "sunshine_duration" - ], - "start_date": start_date, - "end_date": end_date, - "timezone": "auto" - } - - try: - responses = self.openmeteo.weather_api(url, params=params) - response = responses[0] # Process first location - - print(f"Coordinates: {response.Latitude()}ยฐN {response.Longitude()}ยฐE") - print(f"Elevation: {response.Elevation()} m asl") - print(f"Timezone: UTC{response.UtcOffsetSeconds()//3600:+d}") - - # Process daily data - daily = response.Daily() - - # Extract data as numpy arrays (much faster!) - daily_data = { - "date": pd.date_range( - start=pd.to_datetime(daily.Time(), unit="s", utc=True), - end=pd.to_datetime(daily.TimeEnd(), unit="s", utc=True), - freq=pd.Timedelta(seconds=daily.Interval()), - inclusive="left" - ), - "temperature_2m_max": daily.Variables(0).ValuesAsNumpy(), - "temperature_2m_min": daily.Variables(1).ValuesAsNumpy(), - "precipitation_sum": daily.Variables(2).ValuesAsNumpy(), - "rain_sum": daily.Variables(3).ValuesAsNumpy(), - "uv_index_max": daily.Variables(4).ValuesAsNumpy(), - "sunshine_duration": daily.Variables(5).ValuesAsNumpy() - } - - # Create DataFrame for easy analysis - daily_dataframe = pd.DataFrame(data=daily_data) - - return daily_dataframe, response - - except Exception as e: - print(f"Error fetching weather data: {e}") - return None, None - - def analyze_weather_for_plants(self, weather_df): - """Analyze weather data and create plant-specific metrics""" - - if weather_df is None or weather_df.empty: - return None - - # Handle NaN values by filling with 0 or mean - weather_df = weather_df.fillna(0) - - # Calculate plant-relevant metrics using pandas (more efficient) - plant_conditions = { - "avg_temp_max": round(weather_df['temperature_2m_max'].mean(), 1), - "avg_temp_min": round(weather_df['temperature_2m_min'].mean(), 1), - "total_precipitation": round(weather_df['precipitation_sum'].sum(), 1), - "total_rain": round(weather_df['rain_sum'].sum(), 1), - "total_sunshine_hours": round(weather_df['sunshine_duration'].sum() / 3600, 1), # Convert to hours - "max_uv_index": round(weather_df['uv_index_max'].max(), 1), - "days_analyzed": len(weather_df), - "temp_range": round(weather_df['temperature_2m_max'].max() - weather_df['temperature_2m_min'].min(), 1) - } - - return plant_conditions - - def create_transformation_prompt(self, image_path, plant_conditions): - """Create a detailed prompt for image transformation based on weather AND image analysis""" - - if not plant_conditions: - return "Show this plant after one week of growth", "generic plant", "unknown health" - - # STEP 3A: Analyze original image - plant_type = "generic plant" - plant_health = "unknown health" - - try: - image = Image.open(image_path).convert("RGB") - # Basic image analysis - width, height = image.size - aspect_ratio = width / height - - # Simple plant type detection based on image characteristics - plant_type = self.detect_plant_type(image) - plant_health = self.assess_plant_health(image) - - print(f"๐Ÿ“ธ Image Analysis:") - print(f" Plant type detected: {plant_type}") - print(f" Current health: {plant_health}") - print(f" Image size: {width}x{height}") - - except Exception as e: - print(f"Warning: Could not analyze image: {e}") - plant_type = "generic plant" - plant_health = "healthy" - - # STEP 3B: Weather analysis with plant-specific logic - temp_avg = (plant_conditions['avg_temp_max'] + plant_conditions['avg_temp_min']) / 2 - - # Temperature effects (adjusted by plant type) - if plant_type == "basil" or "herb" in plant_type: - if temp_avg > 25: - temp_effect = "warm weather promoting vigorous basil growth with larger, aromatic leaves and bushier structure" - elif temp_avg < 15: - temp_effect = "cool weather slowing basil growth with smaller, less vibrant leaves" - else: - temp_effect = "optimal temperature for basil supporting steady growth with healthy green foliage" - else: - if temp_avg > 25: - temp_effect = "warm weather promoting vigorous growth with larger, darker green leaves" - elif temp_avg < 10: - temp_effect = "cool weather slowing growth with smaller, pale leaves" - else: - temp_effect = "moderate temperature supporting steady growth with healthy green foliage" - - # Water effects - if plant_conditions['total_rain'] > 20: - water_effect = "abundant rainfall keeping leaves lush, turgid and deep green" - elif plant_conditions['total_rain'] < 5: - water_effect = "dry conditions causing slight leaf wilting and browning at edges" - else: - water_effect = "adequate moisture maintaining crisp, healthy leaf appearance" - - # Sunlight effects - if plant_conditions['total_sunshine_hours'] > 50: - sun_effect = "plenty of sunlight encouraging dense, compact foliage growth" - elif plant_conditions['total_sunshine_hours'] < 20: - sun_effect = "limited sunlight causing elongated stems and sparse leaf growth" - else: - sun_effect = "moderate sunlight supporting balanced, proportional growth" - - # UV effects - if plant_conditions['max_uv_index'] > 7: - uv_effect = "high UV causing slight leaf thickening and waxy appearance" - else: - uv_effect = "moderate UV maintaining normal leaf texture" - - # STEP 3C: Create comprehensive prompt combining image + weather analysis - - -# // FINAL PROMT HERE FOR PLANT - - - prompt = f"""Transform this {plant_type} showing realistic growth after {plant_conditions['days_analyzed']} days. The plant should still be realistic and its surrounding how it would look like in the real world and a human should be able to say the picture looks normal and only focus on the plant. Current state: {plant_health}. Apply these weather effects: {temp_effect}, {water_effect}, {sun_effect}, and {uv_effect}. Show natural changes in leaf size, color saturation, stem thickness, and overall plant structure while maintaining the original composition and lighting. Weather summary: {plant_conditions['avg_temp_min']}-{plant_conditions['avg_temp_max']}ยฐC, {plant_conditions['total_rain']}mm rain, {plant_conditions['total_sunshine_hours']}h sun""" - return prompt, plant_type, plant_health - - def detect_plant_type(self, image): - """Simple plant type detection based on image characteristics""" - # This is a simplified version - in a real app you'd use a plant classification model - # For now, we'll do basic analysis - - # Convert to array for analysis - img_array = np.array(image) - - # Analyze color distribution - green_pixels = np.sum((img_array[:,:,1] > img_array[:,:,0]) & (img_array[:,:,1] > img_array[:,:,2])) - total_pixels = img_array.shape[0] * img_array.shape[1] - green_ratio = green_pixels / total_pixels - - # Simple heuristics (could be improved with ML) - if green_ratio > 0.4: - return "basil" # Assume basil for high green content - else: - return "generic plant" - - def assess_plant_health(self, image): - """Assess basic plant health from image""" - img_array = np.array(image) - - # Analyze brightness and color vibrancy - brightness = np.mean(img_array) - green_channel = np.mean(img_array[:,:,1]) - - if brightness > 150 and green_channel > 120: - return "healthy and vibrant" - elif brightness > 100 and green_channel > 80: - return "moderately healthy" - else: - return "showing some stress" - - def transform_plant_image(self, image_path, prompt, num_samples=1): - """STEP 4: Generate ULTRA HIGH-QUALITY image with 60 inference steps""" - - if self.image_model is None: - self.load_image_model() - - try: - # Load and prepare image with HIGHER RESOLUTION - print(f"๐Ÿ“ธ Loading image for high-quality processing: {image_path}") - image = Image.open(image_path).convert("RGB") - original_size = image.size - - # Use HIGHER resolution for better quality (up to 1024x1024) - max_size = 1024 # Increased from 512 for better quality - if max(image.size) < max_size: - # Upscale smaller images for better quality - scale_factor = max_size / max(image.size) - new_size = (int(image.size[0] * scale_factor), int(image.size[1] * scale_factor)) - image = image.resize(new_size, Image.Resampling.LANCZOS) - print(f"๐Ÿ“ˆ Upscaled image from {original_size} to {image.size} for better quality") - elif max(image.size) > max_size: - # Resize but maintain higher resolution - image.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) - print(f"๐Ÿ“ Resized image from {original_size} to {image.size}") - - print(f"๐ŸŽจ Generating 1 ULTRA HIGH-QUALITY sample with 60 inference steps...") - print(f"๐Ÿ“ Using enhanced prompt: {prompt[:120]}...") - - generated_images = [] - - # Clear GPU cache before generation - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - for i in range(num_samples): - print(f"๐Ÿ”„ Generating ultra high-quality sample {i+1}/{num_samples} with 60 steps...") - - # Use different seeds for variety - seed = 42 + i * 137 # Prime number spacing for better variety - generator = torch.Generator(device="cuda" if torch.cuda.is_available() else "cpu").manual_seed(seed) - - # ULTRA HIGH-QUALITY SETTINGS (60 steps for maximum quality) - result = self.image_model( - prompt, - image=image, - num_inference_steps=60, # Increased to 60 for ultra high quality - image_guidance_scale=2.0, # Increased from 1.5 for stronger conditioning - guidance_scale=9.0, # Increased from 7.5 for better prompt following - generator=generator, - eta=0.0, # Deterministic for better quality - # Add additional quality parameters - ).images[0] - - generated_images.append(result) - print(f"โœ… Ultra high-quality sample {i+1} completed with 60 inference steps!") - - # Clean up GPU memory between generations - if torch.cuda.is_available(): - torch.cuda.empty_cache() - - print(f"๐ŸŽ‰ Ultra high-quality sample generated with 60 inference steps!") - return generated_images - - except torch.cuda.OutOfMemoryError: - print("โŒ GPU out of memory! Try reducing num_samples or image resolution") - print("๐Ÿ’ก Current settings are optimized for high-end GPUs") - if torch.cuda.is_available(): - torch.cuda.empty_cache() - return None - except Exception as e: - print(f"โŒ Error transforming image: {e}") - if torch.cuda.is_available(): - torch.cuda.empty_cache() - return None - - def predict_plant_growth(self, image_path, lat=None, lon=None, output_path="predicted_plant.jpg", days=7, num_samples=1, high_quality=True): - """Complete ULTRA HIGH-QUALITY pipeline with 60 inference steps for maximum quality""" - - # Auto-detect location if not provided - if lat is None or lon is None: - print("๐ŸŒ Auto-detecting location...") - lat, lon = self.get_current_location() - - print(f"๐ŸŒฑ Starting ULTRA HIGH-QUALITY plant prediction for coordinates: {lat:.4f}, {lon:.4f}") - print(f"๐Ÿ“… Analyzing {days} days of weather data...") - print(f"๐ŸŽฏ Generating 1 ultra high-quality sample with 60 inference steps") - print(f"โš ๏ธ This will take longer but produce maximum quality results") - - # Step 1: Get weather data using official Open-Meteo client - print("๐ŸŒค๏ธ Fetching weather data with caching and retry...") - weather_df, response_info = self.get_weather_forecast(lat, lon, days) - - if weather_df is None: - print("โŒ Failed to get weather data") - return None - - print(f"โœ… Weather data retrieved for {len(weather_df)} days") - print("\n๐Ÿ“Š Weather Overview:") - print(weather_df[['date', 'temperature_2m_max', 'temperature_2m_min', 'precipitation_sum', 'sunshine_duration']].head()) - - # Step 2: Analyze weather for plants - plant_conditions = self.analyze_weather_for_plants(weather_df) - print(f"\n๐Ÿ”ฌ Plant-specific weather analysis: {plant_conditions}") - - # Step 3: Analyze image + weather to create intelligent prompt - print("\n๐Ÿง  STEP 3: Advanced image analysis and prompt creation...") - try: - prompt, plant_type, plant_health = self.create_transformation_prompt(image_path, plant_conditions) - print(f"๐ŸŒฟ Plant identified as: {plant_type}") - print(f"๐Ÿ’š Current health: {plant_health}") - except Exception as e: - print(f"โŒ Error in Step 3: {e}") - return None - - # Step 4: Generate ULTRA HIGH-QUALITY transformed image - print(f"\n STEP 4: Generating 1 prediction with 60 inference steps...") - print(" This may take 5-8 minutes for absolute maximum quality...") - - import time - start_time = time.time() - - try: - result_images = self.transform_plant_image(image_path, prompt, num_samples=num_samples) - except Exception as e: - print(f" Error in Step 4: {e}") - return None - - end_time = time.time() - total_time = end_time - start_time - - if result_images and len(result_images) > 0: - # Save the ultra high-quality result - saved_paths = [] - - # Save with maximum quality JPEG settings - result_images[0].save(output_path, "JPEG", quality=98, optimize=True) - saved_paths.append(output_path) - print(f" prediction saved to: {output_path}") - - # Create comparison with original - self.create_comparison_grid(image_path, result_images, f"{output_path.replace('.jpg', '')}_comparison.jpg") - - print(f"โฑ๏ธ Total generation time: {total_time:.1f} seconds") - print(f"๐Ÿ† Generated with 60 inference steps for maximum quality!") - - # GPU memory usage info - if torch.cuda.is_available(): - memory_used = torch.cuda.max_memory_allocated() / 1024**3 - print(f" Peak GPU memory usage: {memory_used:.2f} GB") - torch.cuda.reset_peak_memory_stats() - - return result_images, plant_conditions, weather_df, plant_type, plant_health, saved_paths - else: - print(" Failed to generate image") - return None - - def create_comparison_grid(self, original_path, generated_images, output_path): - """Create a comparison grid""" - try: - from PIL import Image, ImageDraw, ImageFont - - # Load original - original = Image.open(original_path).convert("RGB") - - # Use higher resolution for grid - target_size = (512, 512) - original = original.resize(target_size, Image.Resampling.LANCZOS) - resized_generated = [img.resize(target_size, Image.Resampling.LANCZOS) for img in generated_images] - - # Calculate grid - total_images = len(generated_images) + 1 - cols = min(3, total_images) # 3 columns max for better layout - rows = (total_images + cols - 1) // cols - - # Create high-quality grid - grid_width = cols * target_size[0] - grid_height = rows * target_size[1] + 80 # More space for labels - grid_image = Image.new('RGB', (grid_width, grid_height), 'white') - - # Add images - grid_image.paste(original, (0, 80)) - for i, img in enumerate(resized_generated): - col = (i + 1) % cols - row = (i + 1) // cols - x = col * target_size[0] - y = row * target_size[1] + 80 - grid_image.paste(img, (x, y)) - - # Add labels - try: - draw = ImageDraw.Draw(grid_image) - try: - font = ImageFont.truetype("arial.ttf", 32) # Larger font - except: - font = ImageFont.load_default() - - draw.text((10, 20), "Original", fill='black', font=font) - for i in range(len(resized_generated)): - col = (i + 1) % cols - x = col * target_size[0] + 10 - draw.text((x, 20), f"HQ Sample {i+1}", fill='black', font=font) - except: - pass - - # Save with high quality - grid_image.save(output_path, "JPEG", quality=95, optimize=True) - print(f" High-quality comparison grid saved to: {output_path}") - - except Exception as e: - print(f" Could not create comparison grid: {e}") - -# Example usage - HIGH QUALITY MODE -if __name__ == "__main__": - # Initialize predictor - predictor = PlantPredictor() - - # Example coordinates (Milan, Italy) - latitude = 45.4642 - longitude = 9.1900 - - print(" Starting ULTRA HIGH-QUALITY plant prediction with 60 inference steps...") - print(" This will use maximum GPU power and time for absolute best quality") - - # Ultra high-quality prediction with single sample - result = predictor.predict_plant_growth( - image_path="./foto/basilico1 originale.png", - lat=latitude, - lon=longitude, - output_path="./predicted_plant_ultra_hq.jpg", - days=7, - num_samples=1, # Single ultra high-quality sample - high_quality=True - ) - - if result: - images, conditions, weather_data, plant_type, plant_health, saved_paths = result - print("\n" + "="*60) - print("๐ŸŽ‰ PLANT PREDICTION COMPLETED!") - print("="*60) - print(f"๐ŸŒฟ Plant type: {plant_type}") - print(f"๐Ÿ’š Plant health: {plant_health}") - print(f"๐ŸŽฏ Generated 1 ultra high-quality sample with 60 inference steps") - print(f"๐Ÿ“Š Weather data points: {weather_data.shape}") - print(f"๐ŸŒก๏ธ Temperature range: {conditions['avg_temp_min']}ยฐC to {conditions['avg_temp_max']}ยฐC") - print(f"๐ŸŒง๏ธ Total precipitation: {conditions['total_rain']}mm") - print(f"โ˜€๏ธ Sunshine hours: {conditions['total_sunshine_hours']}h") - - print(f"\n๐Ÿ’พ Saved files:") - print(f" ๐Ÿ“ธ Ultra HQ prediction: ./predicted_plant_ultra_hq.jpg") - print(f" ๐Ÿ“Š Comparison image: ./predicted_plant_ultra_hq_comparison.jpg") - - print(f"\n๐Ÿ† Ultra quality improvements:") - print(f" โœ… 60 inference steps (maximum quality)") - print(f" โœ… Higher guidance scales for perfect accuracy") - print(f" โœ… Up to 1024x1024 resolution support") - print(f" โœ… Single focused sample for consistency") - print(f" โœ… Enhanced prompt engineering") - print(f" โœ… Maximum quality JPEG compression (98%)") - print("") - - else: - print("โŒ Ultra high-quality plant prediction failed.") - print("๐Ÿ’ก Check GPU memory and ensure RTX 3060 is available") \ No newline at end of file