Compare commits
11 commits
main
...
challenge-
Author | SHA1 | Date | |
---|---|---|---|
1ba28e04c2 | |||
3efab4f59b | |||
1ca5a639d5 | |||
8bc45ad6fd | |||
a91654df03 | |||
6b93f1206d | |||
025eee7644 | |||
f70fe3cdd1 | |||
1ce7aea6b5 | |||
5c84b2fb59 | |||
0749836d17 |
BIN
.DS_Store
vendored
1
.gitignore
vendored
|
@ -1,2 +1 @@
|
|||
.vscode
|
||||
CHALLENGE_2/sleepysound/lib/services/SPOTIFY_SECRET.dart
|
||||
|
|
5
CHALLENGE_2/sleepysound/.gitignore
vendored
|
@ -1,3 +1,8 @@
|
|||
# API Secret Spotify
|
||||
SPOTIFY_SECRET.dart
|
||||
|
||||
|
||||
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
|
|
133
CHALLENGE_2/sleepysound/README_SPOTIFY.md
Normal file
|
@ -0,0 +1,133 @@
|
|||
# 🎵 SleepySound - Spotify Integration Setup
|
||||
|
||||
## Quick Start
|
||||
|
||||
The app now loads Spotify credentials from `lib/services/SPOTIFY_SECRET.dart`. You have two options:
|
||||
|
||||
### Option 1: Demo Mode (Works Immediately)
|
||||
- The app works perfectly with realistic demo data
|
||||
- No setup required - just run the app!
|
||||
- All features work: search, voting, queue management
|
||||
|
||||
### Option 2: Real Spotify Integration
|
||||
|
||||
1. **Get Spotify API Credentials:**
|
||||
- Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
||||
- Create a new app called "SleepySound"
|
||||
- Copy your Client ID and Client Secret
|
||||
|
||||
2. **Update the Secret File:**
|
||||
```dart
|
||||
// In lib/services/SPOTIFY_SECRET.dart
|
||||
class SpotifyCredentials {
|
||||
static const String clientId = 'your_actual_client_id_here';
|
||||
static const String clientSecret = 'your_actual_client_secret_here';
|
||||
}
|
||||
```
|
||||
|
||||
3. **Run the App:**
|
||||
- The app automatically detects valid credentials
|
||||
- Real Spotify search will be enabled
|
||||
- You'll see "🎵 Spotify" instead of "🎮 Demo" in the UI
|
||||
|
||||
## How It Works
|
||||
|
||||
### 🔄 Automatic Credential Detection
|
||||
```dart
|
||||
// The service automatically checks for valid credentials
|
||||
bool get _hasValidCredentials =>
|
||||
_clientId != 'YOUR_SPOTIFY_CLIENT_ID' &&
|
||||
_clientSecret != 'YOUR_SPOTIFY_CLIENT_SECRET';
|
||||
```
|
||||
|
||||
### 🎮 Graceful Fallback
|
||||
- **Invalid/Missing Credentials** → Demo data
|
||||
- **Valid Credentials** → Real Spotify API
|
||||
- **API Errors** → Falls back to demo data
|
||||
|
||||
### 🎯 Visual Indicators
|
||||
- **"🎵 Spotify"** badge = Real API active
|
||||
- **"🎮 Demo"** badge = Using demo data
|
||||
- Console logs show configuration status
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Working Now (Demo Mode)
|
||||
- Song search with realistic results
|
||||
- Upvote/downvote queue management
|
||||
- Real-time queue reordering
|
||||
- Album artwork simulation
|
||||
- Location-based group features
|
||||
|
||||
### ✅ Enhanced with Real Spotify
|
||||
- Actual Spotify track search
|
||||
- Real album artwork
|
||||
- Accurate track metadata
|
||||
- External Spotify links
|
||||
- Preview URLs (where available)
|
||||
|
||||
## Security Notes
|
||||
|
||||
⚠️ **Important:** Never commit real credentials to version control!
|
||||
|
||||
```bash
|
||||
# Add this to .gitignore
|
||||
lib/services/SPOTIFY_SECRET.dart
|
||||
```
|
||||
|
||||
For production apps:
|
||||
- Use environment variables
|
||||
- Use secure credential storage
|
||||
- Implement proper OAuth flows
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── services/
|
||||
│ ├── spotify_service.dart # Main Spotify API service
|
||||
│ └── SPOTIFY_SECRET.dart # Your credentials (gitignored)
|
||||
├── models/
|
||||
│ └── spotify_track.dart # Spotify data models
|
||||
└── pages/
|
||||
├── voting_page.dart # Search & voting interface
|
||||
├── now_playing_page.dart # Current queue display
|
||||
└── group_page.dart # Location & group features
|
||||
```
|
||||
|
||||
## API Integration Details
|
||||
|
||||
### Client Credentials Flow
|
||||
- Used for public track search (no user login required)
|
||||
- Perfect for the collaborative jukebox use case
|
||||
- Handles token refresh automatically
|
||||
|
||||
### Search Functionality
|
||||
```dart
|
||||
// Real Spotify search
|
||||
final tracks = await _spotifyService.searchTracks('summer vibes', limit: 10);
|
||||
|
||||
// Automatic fallback to demo data if API unavailable
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Network errors → Demo data
|
||||
- Invalid credentials → Demo data
|
||||
- Rate limiting → Demo data
|
||||
- Token expiration → Automatic refresh
|
||||
|
||||
## Challenge Requirements ✅
|
||||
|
||||
- ✅ **Music streaming API integration** - Spotify Web API
|
||||
- ✅ **Track metadata retrieval** - Full track info + artwork
|
||||
- ✅ **Demo-ready functionality** - Works without setup
|
||||
- ✅ **Real-world usability** - Graceful fallbacks
|
||||
|
||||
## Development Tips
|
||||
|
||||
1. **Start with Demo Mode** - Get familiar with the app
|
||||
2. **Add Real Credentials** - See the enhanced experience
|
||||
3. **Test Both Modes** - Ensure fallbacks work
|
||||
4. **Check Console Logs** - See API status messages
|
||||
|
||||
Enjoy building your collaborative music experience! 🎶
|
68
CHALLENGE_2/sleepysound/SPOTIFY_SETUP.md
Normal file
|
@ -0,0 +1,68 @@
|
|||
# Spotify API Setup Instructions
|
||||
|
||||
## 🎵 Getting Spotify API Credentials
|
||||
|
||||
To enable real Spotify integration in SleepySound, you need to set up a Spotify Developer account and get API credentials.
|
||||
|
||||
### Step 1: Create a Spotify Developer Account
|
||||
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
||||
2. Log in with your Spotify account (create one if needed)
|
||||
3. Accept the Terms of Service
|
||||
|
||||
### Step 2: Create a New App
|
||||
1. Click "Create an App"
|
||||
2. Fill in the details:
|
||||
- **App Name**: SleepySound
|
||||
- **App Description**: Collaborative music selection for Lido Schenna
|
||||
- **Redirect URI**: `sleepysound://callback`
|
||||
3. Check the boxes to agree to terms
|
||||
4. Click "Create"
|
||||
|
||||
### Step 3: Get Your Credentials
|
||||
1. In your app dashboard, you'll see:
|
||||
- **Client ID** (public)
|
||||
- **Client Secret** (keep this private!)
|
||||
2. Copy these values
|
||||
|
||||
### Step 4: Configure the App
|
||||
1. Open `lib/services/spotify_service.dart`
|
||||
2. Replace the placeholder values:
|
||||
```dart
|
||||
static const String _clientId = 'YOUR_ACTUAL_CLIENT_ID_HERE';
|
||||
static const String _clientSecret = 'YOUR_ACTUAL_CLIENT_SECRET_HERE';
|
||||
```
|
||||
|
||||
### Step 5: Enable Spotify Features
|
||||
The app is configured to work with mock data by default. Once you add real credentials:
|
||||
- Real Spotify search will be enabled
|
||||
- Track metadata will be fetched from Spotify
|
||||
- Album artwork will be displayed
|
||||
- Preview URLs will be available (if provided by Spotify)
|
||||
|
||||
## 🚀 Demo Mode
|
||||
The app works without Spotify credentials using demo data. You can:
|
||||
- Search for tracks (returns demo results)
|
||||
- Vote on songs
|
||||
- See the queue update in real-time
|
||||
- Experience the full UI/UX
|
||||
|
||||
## 🔒 Security Notes
|
||||
- Never commit your Client Secret to version control
|
||||
- In production, use environment variables or secure storage
|
||||
- Consider using Spotify's Authorization Code flow for user-specific features
|
||||
|
||||
## 📱 Features Enabled with Spotify API
|
||||
- ✅ Real track search
|
||||
- ✅ Album artwork
|
||||
- ✅ Accurate track duration
|
||||
- ✅ Artist information
|
||||
- ✅ Track previews (where available)
|
||||
- ✅ External Spotify links
|
||||
|
||||
## 🎯 Challenge Requirements Met
|
||||
- ✅ Music streaming API integration (Spotify)
|
||||
- ✅ Track metadata retrieval
|
||||
- ✅ Demo-ready functionality
|
||||
- ✅ Real-world usability
|
||||
|
||||
Enjoy building your collaborative music experience! 🎶
|
BIN
CHALLENGE_2/sleepysound/SleepySound.apk
Normal file
|
@ -8,7 +8,7 @@ plugins {
|
|||
android {
|
||||
namespace = "com.example.sleepysound"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Network permissions for WiFi-based group features -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:label="sleepysound"
|
||||
android:name="${applicationName}"
|
||||
|
|
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 41 KiB |
After Width: | Height: | Size: 104 KiB |
After Width: | Height: | Size: 206 KiB |
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 5.6 KiB |
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 34 KiB |
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#121212</color>
|
||||
</resources>
|
BIN
CHALLENGE_2/sleepysound/assets/icons/app_icon.png
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
CHALLENGE_2/sleepysound/assets/icons/app_icon_foreground.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
|
@ -427,7 +427,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
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++";
|
||||
|
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 780 B |
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 9.7 KiB |
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 5.6 KiB |
After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
|
@ -1,4 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'pages/now_playing_page.dart';
|
||||
import 'pages/voting_page.dart';
|
||||
import 'pages/group_page.dart';
|
||||
import 'services/music_queue_service.dart';
|
||||
import 'services/network_group_service.dart';
|
||||
import 'services/spam_protection_service.dart';
|
||||
import 'services/audio_service.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
|
@ -10,20 +18,42 @@ class MyApp extends StatelessWidget {
|
|||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'SleepySound',
|
||||
theme: ThemeData(
|
||||
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (context) => MusicQueueService()),
|
||||
ChangeNotifierProvider(create: (context) => NetworkGroupService()),
|
||||
ChangeNotifierProvider(create: (context) => SpamProtectionService()),
|
||||
ChangeNotifierProvider(create: (context) => AudioService()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'LidoSound',
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF6366F1),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF1E1E1E),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: Color(0xFF1E1E1E),
|
||||
selectedItemColor: Color(0xFF6366F1),
|
||||
unselectedItemColor: Colors.grey,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
),
|
||||
),
|
||||
home: const MyHomePage(title: 'Now Playing'),
|
||||
),
|
||||
home: const MyHomePage(title: 'Now Playing'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
|
@ -31,69 +61,53 @@ class MyHomePage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
int _selectedIndex = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
void _onItemTapped(int index) {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
_selectedIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _getSelectedPage() {
|
||||
switch (_selectedIndex) {
|
||||
case 0:
|
||||
return const NowPlayingPage();
|
||||
case 1:
|
||||
return const VotingPage();
|
||||
case 2:
|
||||
return const GroupPage();
|
||||
default:
|
||||
return const NowPlayingPage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
body: _getSelectedPage(),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
items: const <BottomNavigationBarItem>[
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.play_circle_filled),
|
||||
label: 'Now Playing',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.how_to_vote),
|
||||
label: 'Voting',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.group),
|
||||
label: 'Group',
|
||||
),
|
||||
],
|
||||
currentIndex: _selectedIndex,
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
selectedItemColor: const Color(0xFF6366F1),
|
||||
unselectedItemColor: Colors.grey,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
onTap: _onItemTapped,
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
), // This trailing comma makes auto-formatting nicer for build methods.
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
122
CHALLENGE_2/sleepysound/lib/models/spotify_track.dart
Normal file
|
@ -0,0 +1,122 @@
|
|||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'spotify_track.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class SpotifyTrack {
|
||||
final String id;
|
||||
final String name;
|
||||
final List<SpotifyArtist> artists;
|
||||
final SpotifyAlbum album;
|
||||
@JsonKey(name: 'duration_ms')
|
||||
final int durationMs;
|
||||
@JsonKey(name: 'external_urls')
|
||||
final Map<String, String> externalUrls;
|
||||
@JsonKey(name: 'preview_url')
|
||||
final String? previewUrl;
|
||||
|
||||
SpotifyTrack({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.artists,
|
||||
required this.album,
|
||||
required this.durationMs,
|
||||
required this.externalUrls,
|
||||
this.previewUrl,
|
||||
});
|
||||
|
||||
factory SpotifyTrack.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifyTrackFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SpotifyTrackToJson(this);
|
||||
|
||||
String get artistNames => artists.map((artist) => artist.name).join(', ');
|
||||
|
||||
String get duration {
|
||||
final minutes = (durationMs / 60000).floor();
|
||||
final seconds = ((durationMs % 60000) / 1000).floor();
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String get imageUrl => album.images.isNotEmpty ? album.images.first.url : '';
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SpotifyArtist {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
SpotifyArtist({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory SpotifyArtist.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifyArtistFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SpotifyArtistToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SpotifyAlbum {
|
||||
final String id;
|
||||
final String name;
|
||||
final List<SpotifyImage> images;
|
||||
|
||||
SpotifyAlbum({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.images,
|
||||
});
|
||||
|
||||
factory SpotifyAlbum.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifyAlbumFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SpotifyAlbumToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SpotifyImage {
|
||||
final int height;
|
||||
final int width;
|
||||
final String url;
|
||||
|
||||
SpotifyImage({
|
||||
required this.height,
|
||||
required this.width,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
factory SpotifyImage.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifyImageFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SpotifyImageToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SpotifySearchResponse {
|
||||
final SpotifyTracks tracks;
|
||||
|
||||
SpotifySearchResponse({required this.tracks});
|
||||
|
||||
factory SpotifySearchResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifySearchResponseFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SpotifySearchResponseToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SpotifyTracks {
|
||||
final List<SpotifyTrack> items;
|
||||
final int total;
|
||||
|
||||
SpotifyTracks({
|
||||
required this.items,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
factory SpotifyTracks.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpotifyTracksFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SpotifyTracksToJson(this);
|
||||
}
|
88
CHALLENGE_2/sleepysound/lib/models/spotify_track.g.dart
Normal file
|
@ -0,0 +1,88 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'spotify_track.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SpotifyTrack _$SpotifyTrackFromJson(Map<String, dynamic> json) => SpotifyTrack(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
artists:
|
||||
(json['artists'] as List<dynamic>)
|
||||
.map((e) => SpotifyArtist.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
album: SpotifyAlbum.fromJson(json['album'] as Map<String, dynamic>),
|
||||
durationMs: (json['duration_ms'] as num).toInt(),
|
||||
externalUrls: Map<String, String>.from(json['external_urls'] as Map),
|
||||
previewUrl: json['preview_url'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SpotifyTrackToJson(SpotifyTrack instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'artists': instance.artists,
|
||||
'album': instance.album,
|
||||
'duration_ms': instance.durationMs,
|
||||
'external_urls': instance.externalUrls,
|
||||
'preview_url': instance.previewUrl,
|
||||
};
|
||||
|
||||
SpotifyArtist _$SpotifyArtistFromJson(Map<String, dynamic> json) =>
|
||||
SpotifyArtist(id: json['id'] as String, name: json['name'] as String);
|
||||
|
||||
Map<String, dynamic> _$SpotifyArtistToJson(SpotifyArtist instance) =>
|
||||
<String, dynamic>{'id': instance.id, 'name': instance.name};
|
||||
|
||||
SpotifyAlbum _$SpotifyAlbumFromJson(Map<String, dynamic> json) => SpotifyAlbum(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
images:
|
||||
(json['images'] as List<dynamic>)
|
||||
.map((e) => SpotifyImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SpotifyAlbumToJson(SpotifyAlbum instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'images': instance.images,
|
||||
};
|
||||
|
||||
SpotifyImage _$SpotifyImageFromJson(Map<String, dynamic> json) => SpotifyImage(
|
||||
height: (json['height'] as num).toInt(),
|
||||
width: (json['width'] as num).toInt(),
|
||||
url: json['url'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SpotifyImageToJson(SpotifyImage instance) =>
|
||||
<String, dynamic>{
|
||||
'height': instance.height,
|
||||
'width': instance.width,
|
||||
'url': instance.url,
|
||||
};
|
||||
|
||||
SpotifySearchResponse _$SpotifySearchResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => SpotifySearchResponse(
|
||||
tracks: SpotifyTracks.fromJson(json['tracks'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SpotifySearchResponseToJson(
|
||||
SpotifySearchResponse instance,
|
||||
) => <String, dynamic>{'tracks': instance.tracks};
|
||||
|
||||
SpotifyTracks _$SpotifyTracksFromJson(Map<String, dynamic> json) =>
|
||||
SpotifyTracks(
|
||||
items:
|
||||
(json['items'] as List<dynamic>)
|
||||
.map((e) => SpotifyTrack.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: (json['total'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SpotifyTracksToJson(SpotifyTracks instance) =>
|
||||
<String, dynamic>{'items': instance.items, 'total': instance.total};
|
517
CHALLENGE_2/sleepysound/lib/pages/group_page.dart
Normal file
|
@ -0,0 +1,517 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/network_group_service.dart';
|
||||
|
||||
class GroupPage extends StatefulWidget {
|
||||
const GroupPage({super.key});
|
||||
|
||||
@override
|
||||
State<GroupPage> createState() => _GroupPageState();
|
||||
}
|
||||
|
||||
class _GroupPageState extends State<GroupPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<NetworkGroupService>(
|
||||
builder: (context, networkService, child) {
|
||||
final isConnected = networkService.isConnectedToWifi;
|
||||
final networkName = networkService.currentNetworkName;
|
||||
final currentUser = networkService.currentUser;
|
||||
final networkUsers = networkService.networkUsers;
|
||||
final onlineCount = networkService.onlineUsersCount;
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF121212),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// WiFi Connection Status Card
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isConnected
|
||||
? const Color(0xFF22C55E).withOpacity(0.1)
|
||||
: const Color(0xFFEF4444).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: isConnected
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFEF4444),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
isConnected ? Icons.wifi : Icons.wifi_off,
|
||||
size: 40,
|
||||
color: isConnected
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFEF4444),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
isConnected
|
||||
? '<EFBFBD> Connected to $networkName'
|
||||
: '❌ Not connected to WiFi',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isConnected
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFEF4444),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
isConnected
|
||||
? 'You can now vote and suggest music with others on this network!'
|
||||
: 'Please connect to WiFi to join the group session',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isConnected && networkService.localIpAddress.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'IP: ${networkService.localIpAddress}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Current User Info Section
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 25,
|
||||
backgroundColor: const Color(0xFF6366F1),
|
||||
child: Text(
|
||||
currentUser.name.substring(0, 1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentUser.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Active since ${_formatDuration(DateTime.now().difference(currentUser.joinedAt))} ago',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.how_to_vote,
|
||||
color: Color(0xFF6366F1),
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${currentUser.votes} votes',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Network Users Section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Network Users',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$onlineCount online',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Users List
|
||||
Expanded(
|
||||
child: isConnected
|
||||
? networkUsers.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Searching for other users...',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Make sure others are connected to the same WiFi network',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: networkUsers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = networkUsers[index];
|
||||
final isCurrentUser = user.id == currentUser.id;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentUser
|
||||
? const Color(0xFF6366F1).withOpacity(0.1)
|
||||
: const Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isCurrentUser
|
||||
? Border.all(color: const Color(0xFF6366F1), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: isCurrentUser
|
||||
? const Color(0xFF6366F1)
|
||||
: const Color(0xFF4B5563),
|
||||
child: Text(
|
||||
user.name.substring(0, 1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (user.isOnline)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF22C55E),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFF121212), width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
user.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (isCurrentUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'You',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (user.isListening && user.currentTrackName != null) ...[
|
||||
const Icon(
|
||||
Icons.music_note,
|
||||
color: Color(0xFF6366F1),
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Listening to "${user.currentTrackName}" by ${user.currentArtist}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
'Joined ${_formatDuration(DateTime.now().difference(user.joinedAt))} ago',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!user.isOnline) ...[
|
||||
const Text(
|
||||
' • ',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
const Text(
|
||||
'Offline',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (user.ipAddress.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'IP: ${user.ipAddress}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
if (user.isListening && !isCurrentUser) ...[
|
||||
// Join Listening Session Button
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final success = await networkService.joinListeningSession(user);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? 'Joined ${user.name}\'s listening session! 🎵'
|
||||
: 'Failed to join listening session',
|
||||
),
|
||||
backgroundColor: success
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFEF4444),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.headphones,
|
||||
color: Color(0xFF6366F1),
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Join listening session',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${user.votes} votes',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.wifi_off,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Connect to WiFi to join a group session',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom Action Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: isConnected ? () async {
|
||||
// Refresh network discovery
|
||||
await networkService.refreshNetwork();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Network refreshed! ✓'),
|
||||
backgroundColor: Color(0xFF22C55E),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} : null,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Refresh Network'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF6366F1),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
if (duration.inMinutes < 1) {
|
||||
return 'less than a minute';
|
||||
} else if (duration.inMinutes < 60) {
|
||||
return '${duration.inMinutes} minute${duration.inMinutes == 1 ? '' : 's'}';
|
||||
} else {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
if (minutes == 0) {
|
||||
return '$hours hour${hours == 1 ? '' : 's'}';
|
||||
} else {
|
||||
return '$hours hour${hours == 1 ? '' : 's'} $minutes minute${minutes == 1 ? '' : 's'}';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
34
CHALLENGE_2/sleepysound/lib/pages/library_page.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class VotingPage extends StatelessWidget {
|
||||
const VotingPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: const Color(0xFF121212),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.how_to_vote, size: 100, color: Color(0xFF6366F1)),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'Voting',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'Vote for the next song',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
317
CHALLENGE_2/sleepysound/lib/pages/now_playing_page.dart
Normal file
|
@ -0,0 +1,317 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/music_queue_service.dart';
|
||||
import '../models/spotify_track.dart';
|
||||
|
||||
class NowPlayingPage extends StatelessWidget {
|
||||
const NowPlayingPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MusicQueueService>(
|
||||
builder: (context, queueService, child) {
|
||||
final currentTrack = queueService.currentTrack;
|
||||
final queue = queueService.queue;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Now Playing Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: const Text(
|
||||
'Now Playing',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Current Track Display
|
||||
Container(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
margin: const EdgeInsets.all(20),
|
||||
child: currentTrack != null
|
||||
? _buildCurrentTrackCard(context, currentTrack, queueService)
|
||||
: _buildNoTrackCard(),
|
||||
),
|
||||
|
||||
// Playback Controls
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: _buildPlaybackControls(queueService),
|
||||
),
|
||||
|
||||
// Queue Preview
|
||||
Container(
|
||||
height: MediaQuery.of(context).size.height * 0.3,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
'Up Next',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: queue.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No songs in queue\nGo to Voting to add some!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: queue.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildQueueItem(queue[index], index + 1);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20), // Extra padding at bottom
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentTrackCard(BuildContext context, SpotifyTrack currentTrack, MusicQueueService queueService) {
|
||||
return Card(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Album Art
|
||||
Container(
|
||||
width: 160,
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: const Color(0xFF2A2A2A),
|
||||
),
|
||||
child: currentTrack.album.images.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
currentTrack.album.images.first.url,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.music_note,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.music_note,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Track Info
|
||||
Text(
|
||||
currentTrack.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
currentTrack.artists.map((a) => a.name).join(', '),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
currentTrack.album.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Progress Bar
|
||||
const SizedBox(height: 15),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: queueService.progress,
|
||||
backgroundColor: Colors.grey[800],
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF6366F1)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration((queueService.progress * currentTrack.durationMs / 1000).round()),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
currentTrack.duration,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoTrackCard() {
|
||||
return Card(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_off,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'No track playing',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'Add some songs from the Voting tab!',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaybackControls(MusicQueueService queueService) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Previous (disabled for now)
|
||||
IconButton(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
iconSize: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
|
||||
// Play/Pause
|
||||
IconButton(
|
||||
onPressed: queueService.togglePlayPause,
|
||||
icon: Icon(queueService.isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled),
|
||||
iconSize: 60,
|
||||
color: const Color(0xFF6366F1),
|
||||
),
|
||||
|
||||
// Next
|
||||
IconButton(
|
||||
onPressed: queueService.queue.isNotEmpty ? queueService.skipTrack : null,
|
||||
icon: const Icon(Icons.skip_next),
|
||||
iconSize: 40,
|
||||
color: queueService.queue.isNotEmpty ? Colors.white : Colors.grey,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueItem(QueueItem item, int position) {
|
||||
return Card(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(0xFF6366F1),
|
||||
child: Text(
|
||||
'$position',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item.track.name,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
item.track.artists.map((a) => a.name).join(', '),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.thumb_up, color: Colors.green, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${item.votes}',
|
||||
style: const TextStyle(color: Colors.green, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
int minutes = seconds ~/ 60;
|
||||
int remainingSeconds = seconds % 60;
|
||||
return '${minutes}:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
34
CHALLENGE_2/sleepysound/lib/pages/settings_page.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class GroupPage extends StatelessWidget {
|
||||
const GroupPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: const Color(0xFF121212),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.group, size: 100, color: Color(0xFF6366F1)),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'Group',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'Manage your listening group',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
793
CHALLENGE_2/sleepysound/lib/pages/voting_page.dart
Normal file
|
@ -0,0 +1,793 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/music_queue_service.dart';
|
||||
import '../services/spam_protection_service.dart';
|
||||
import '../services/genre_filter_service.dart';
|
||||
import '../models/spotify_track.dart';
|
||||
import '../widgets/user_activity_status.dart';
|
||||
|
||||
class VotingPage extends StatefulWidget {
|
||||
const VotingPage({super.key});
|
||||
|
||||
@override
|
||||
State<VotingPage> createState() => _VotingPageState();
|
||||
}
|
||||
|
||||
class _VotingPageState extends State<VotingPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
List<SpotifyTrack> _searchResults = [];
|
||||
bool _isLoading = false;
|
||||
String _statusMessage = '';
|
||||
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialQueue();
|
||||
_searchFocusNode.addListener(_onSearchFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideSearchOverlay();
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchFocusChange() {
|
||||
if (_searchFocusNode.hasFocus && _searchResults.isNotEmpty) {
|
||||
_showSearchOverlay();
|
||||
} else if (!_searchFocusNode.hasFocus) {
|
||||
// Delay hiding to allow for taps on results
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
_hideSearchOverlay();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showSearchOverlay() {
|
||||
if (_overlayEntry != null) return;
|
||||
|
||||
_overlayEntry = _createOverlayEntry();
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
void _hideSearchOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
OverlayEntry _createOverlayEntry() {
|
||||
RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
var size = renderBox.size;
|
||||
var offset = renderBox.localToGlobal(Offset.zero);
|
||||
|
||||
return OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
left: offset.dx + 20,
|
||||
top: offset.dy + 200, // Adjust based on search field position
|
||||
width: size.width - 40,
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: const Color(0xFF1E1E1E),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF6366F1).withOpacity(0.3)),
|
||||
),
|
||||
child: _buildSearchResultsOverlay(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResultsOverlay() {
|
||||
if (_searchResults.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: const Text(
|
||||
'No results found',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _searchResults.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = _searchResults[index];
|
||||
return _buildSearchResultItem(track, index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResultItem(SpotifyTrack track, int index) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
_addToQueue(track);
|
||||
_hideSearchOverlay();
|
||||
_searchController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album Art
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: const Color(0xFF2A2A2A),
|
||||
),
|
||||
child: track.album.images.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.network(
|
||||
track.album.images.first.url,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 16,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
track.artists.map((a) => a.name).join(', '),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Add Icon
|
||||
const Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: Color(0xFF6366F1),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadInitialQueue() async {
|
||||
final queueService = Provider.of<MusicQueueService>(context, listen: false);
|
||||
await queueService.initializeQueue();
|
||||
}
|
||||
|
||||
Future<void> _searchSpotify(String query) async {
|
||||
if (query.isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_statusMessage = '';
|
||||
});
|
||||
_hideSearchOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if search query is appropriate
|
||||
if (!GenreFilterService.isSearchQueryAppropriate(query)) {
|
||||
final suggestions = GenreFilterService.getAlternativeSearchSuggestions(query);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_statusMessage = 'Search term not suitable for the peaceful Lido atmosphere. Try: ${suggestions.join(', ')}';
|
||||
_searchResults = [];
|
||||
});
|
||||
_hideSearchOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
final spamService = Provider.of<SpamProtectionService>(context, listen: false);
|
||||
final userId = spamService.getCurrentUserId();
|
||||
|
||||
// Check spam protection for suggestions
|
||||
if (!spamService.canSuggest(userId)) {
|
||||
final cooldown = spamService.getSuggestionCooldownRemaining(userId);
|
||||
final blockMessage = spamService.getBlockMessage(userId);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_statusMessage = blockMessage ?? 'Please wait $cooldown seconds before searching again.';
|
||||
_searchResults = [];
|
||||
});
|
||||
_hideSearchOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_statusMessage = 'Searching for "$query"...';
|
||||
});
|
||||
|
||||
try {
|
||||
final queueService = Provider.of<MusicQueueService>(context, listen: false);
|
||||
final results = await queueService.searchTracks(query);
|
||||
|
||||
// No filtering on search results - let users see all tracks
|
||||
// Filtering only happens when adding to queue to maintain atmosphere
|
||||
|
||||
// Record the suggestion attempt
|
||||
spamService.recordSuggestion(userId);
|
||||
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_isLoading = false;
|
||||
if (results.isEmpty) {
|
||||
_statusMessage = 'No tracks found for "$query"';
|
||||
} else {
|
||||
_statusMessage = 'Found ${results.length} tracks';
|
||||
}
|
||||
});
|
||||
|
||||
// Show overlay if we have results and search field is focused
|
||||
if (results.isNotEmpty && _searchFocusNode.hasFocus) {
|
||||
_showSearchOverlay();
|
||||
} else {
|
||||
_hideSearchOverlay();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_statusMessage = 'Search failed: ${e.toString()}';
|
||||
_searchResults = [];
|
||||
});
|
||||
_hideSearchOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
void _addToQueue(SpotifyTrack track) {
|
||||
final spamService = Provider.of<SpamProtectionService>(context, listen: false);
|
||||
final userId = spamService.getCurrentUserId();
|
||||
|
||||
// Check if user can suggest (add to queue)
|
||||
if (!spamService.canSuggest(userId)) {
|
||||
final cooldown = spamService.getSuggestionCooldownRemaining(userId);
|
||||
final blockMessage = spamService.getBlockMessage(userId);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(blockMessage ?? 'Please wait $cooldown seconds before adding another song.'),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if track is appropriate for atmosphere
|
||||
final rejectionReason = GenreFilterService.getRejectionReason(track);
|
||||
if (rejectionReason != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(rejectionReason),
|
||||
duration: const Duration(seconds: 4),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final queueService = Provider.of<MusicQueueService>(context, listen: false);
|
||||
queueService.addToQueue(track);
|
||||
|
||||
// Record the suggestion
|
||||
spamService.recordSuggestion(userId);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Added "${track.name}" to queue'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: const Color(0xFF6366F1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MusicQueueService>(
|
||||
builder: (context, queueService, child) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// User Activity Status
|
||||
Consumer<SpamProtectionService>(
|
||||
builder: (context, spamService, child) {
|
||||
return UserActivityStatus(spamService: spamService);
|
||||
},
|
||||
),
|
||||
|
||||
// Header with Search
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Voting',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
// Status indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6366F1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'🎵 Spotify',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Search Bar
|
||||
CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search for songs, artists, albums...',
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
prefixIcon: const Icon(Icons.search, color: Colors.grey),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6366F1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_searchController.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, color: Colors.grey),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_hideSearchOverlay();
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_statusMessage = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF1E1E1E),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Color(0xFF6366F1), width: 2),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
// Search as user types (with debounce)
|
||||
if (value.length >= 3) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (_searchController.text == value) {
|
||||
_searchSpotify(value);
|
||||
}
|
||||
});
|
||||
} else if (value.isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_statusMessage = '';
|
||||
});
|
||||
_hideSearchOverlay();
|
||||
}
|
||||
},
|
||||
onSubmitted: _searchSpotify,
|
||||
),
|
||||
),
|
||||
|
||||
// Atmosphere Info
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF6366F1).withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.spa, color: Color(0xFF6366F1), size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Lido Atmosphere',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
GenreFilterService.getAtmosphereDescription(),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Try these searches:',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: GenreFilterService.getSuggestedSearchTerms()
|
||||
.take(8)
|
||||
.map((term) => InkWell(
|
||||
onTap: () {
|
||||
_searchController.text = term;
|
||||
_searchSpotify(term);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
term,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Status Message
|
||||
if (_statusMessage.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
_statusMessage,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Queue Section
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Music Queue',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6366F1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${queueService.queue.length} songs',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: _buildQueueView(queueService),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueView(MusicQueueService queueService) {
|
||||
final queue = queueService.queue;
|
||||
|
||||
if (queue.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.queue_music,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'Queue is empty',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'Search and add songs to get started!',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: queue.length,
|
||||
itemBuilder: (context, index) {
|
||||
final queueItem = queue[index];
|
||||
return _buildQueueItemCard(queueItem, index, queueService);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueItemCard(QueueItem queueItem, int index, MusicQueueService queueService) {
|
||||
return Card(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Position
|
||||
CircleAvatar(
|
||||
backgroundColor: const Color(0xFF6366F1),
|
||||
radius: 16,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Album Art
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: const Color(0xFF2A2A2A),
|
||||
),
|
||||
child: queueItem.track.album.images.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.network(
|
||||
queueItem.track.album.images.first.url,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
queueItem.track.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
queueItem.track.artists.map((a) => a.name).join(', '),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Voting Buttons
|
||||
Column(
|
||||
children: [
|
||||
Consumer<SpamProtectionService>(
|
||||
builder: (context, spamService, child) {
|
||||
final userId = spamService.getCurrentUserId();
|
||||
final canVote = spamService.canVote(userId);
|
||||
final cooldown = spamService.getVoteCooldownRemaining(userId);
|
||||
|
||||
return IconButton(
|
||||
onPressed: canVote ? () {
|
||||
queueService.upvote(index);
|
||||
spamService.recordVote(userId);
|
||||
} : null,
|
||||
icon: Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: canVote ? Colors.green : Colors.grey,
|
||||
size: 28,
|
||||
),
|
||||
tooltip: canVote ? 'Upvote' : 'Wait $cooldown seconds',
|
||||
);
|
||||
}
|
||||
),
|
||||
Text(
|
||||
'${queueItem.votes}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Consumer<SpamProtectionService>(
|
||||
builder: (context, spamService, child) {
|
||||
final userId = spamService.getCurrentUserId();
|
||||
final canVote = spamService.canVote(userId);
|
||||
final cooldown = spamService.getVoteCooldownRemaining(userId);
|
||||
|
||||
return IconButton(
|
||||
onPressed: canVote ? () {
|
||||
queueService.downvote(index);
|
||||
spamService.recordVote(userId);
|
||||
} : null,
|
||||
icon: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: canVote ? Colors.red : Colors.grey,
|
||||
size: 28,
|
||||
),
|
||||
tooltip: canVote ? 'Downvote' : 'Wait $cooldown seconds',
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
491
CHALLENGE_2/sleepysound/lib/pages/voting_page_new.dart
Normal file
|
@ -0,0 +1,491 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/music_queue_service.dart';
|
||||
import '../models/spotify_track.dart';
|
||||
|
||||
class VotingPage extends StatefulWidget {
|
||||
const VotingPage({super.key});
|
||||
|
||||
@override
|
||||
State<VotingPage> createState() => _VotingPageState();
|
||||
}
|
||||
|
||||
class _VotingPageState extends State<VotingPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<SpotifyTrack> _searchResults = [];
|
||||
bool _isLoading = false;
|
||||
String _statusMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialQueue();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialQueue() async {
|
||||
final queueService = Provider.of<MusicQueueService>(context, listen: false);
|
||||
await queueService.initializeQueue();
|
||||
}
|
||||
|
||||
Future<void> _searchSpotify(String query) async {
|
||||
if (query.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_statusMessage = 'Searching for "$query"...';
|
||||
});
|
||||
|
||||
try {
|
||||
final queueService = Provider.of<MusicQueueService>(context, listen: false);
|
||||
final results = await queueService.searchTracks(query);
|
||||
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_isLoading = false;
|
||||
_statusMessage = results.isEmpty
|
||||
? 'No tracks found for "$query"'
|
||||
: 'Found ${results.length} tracks';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_statusMessage = 'Search failed: ${e.toString()}';
|
||||
_searchResults = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _addToQueue(SpotifyTrack track) {
|
||||
final queueService = Provider.of<MusicQueueService>(context, listen: false);
|
||||
queueService.addToQueue(track);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Added "${track.name}" to queue'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: const Color(0xFF6366F1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MusicQueueService>(
|
||||
builder: (context, queueService, child) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header with Search
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Voting',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
// Status indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6366F1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'🎵 Spotify',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Search Bar
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search for songs, artists, albums...',
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
prefixIcon: const Icon(Icons.search, color: Colors.grey),
|
||||
suffixIcon: _isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6366F1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF1E1E1E),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: _searchSpotify,
|
||||
),
|
||||
|
||||
// Status Message
|
||||
if (_statusMessage.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
_statusMessage,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Search Results and Queue
|
||||
Expanded(
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
const TabBar(
|
||||
labelColor: Color(0xFF6366F1),
|
||||
unselectedLabelColor: Colors.grey,
|
||||
indicatorColor: Color(0xFF6366F1),
|
||||
tabs: [
|
||||
Tab(text: 'Search Results'),
|
||||
Tab(text: 'Queue'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
// Search Results Tab
|
||||
_buildSearchResults(),
|
||||
// Queue Tab
|
||||
_buildQueueView(queueService),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResults() {
|
||||
if (_searchResults.isEmpty && !_isLoading) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'Search for songs to add to the queue',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: _searchResults.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = _searchResults[index];
|
||||
return _buildTrackCard(track);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueView(MusicQueueService queueService) {
|
||||
final queue = queueService.queue;
|
||||
|
||||
if (queue.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.queue_music,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'Queue is empty',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'Search and add songs to get started!',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: queue.length,
|
||||
itemBuilder: (context, index) {
|
||||
final queueItem = queue[index];
|
||||
return _buildQueueItemCard(queueItem, index, queueService);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackCard(SpotifyTrack track) {
|
||||
return Card(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album Art
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: const Color(0xFF2A2A2A),
|
||||
),
|
||||
child: track.album.images.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
track.album.images.first.url,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
track.artists.map((a) => a.name).join(', '),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
track.album.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Add Button
|
||||
IconButton(
|
||||
onPressed: () => _addToQueue(track),
|
||||
icon: const Icon(
|
||||
Icons.add_circle,
|
||||
color: Color(0xFF6366F1),
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueItemCard(QueueItem queueItem, int index, MusicQueueService queueService) {
|
||||
return Card(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Position
|
||||
CircleAvatar(
|
||||
backgroundColor: const Color(0xFF6366F1),
|
||||
radius: 16,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Album Art
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: const Color(0xFF2A2A2A),
|
||||
),
|
||||
child: queueItem.track.album.images.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.network(
|
||||
queueItem.track.album.images.first.url,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
queueItem.track.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
queueItem.track.artists.map((a) => a.name).join(', '),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Voting Buttons
|
||||
Column(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => queueService.upvote(index),
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: Colors.green,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${queueItem.votes}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => queueService.downvote(index),
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Colors.red,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
242
CHALLENGE_2/sleepysound/lib/services/audio_service.dart
Normal file
|
@ -0,0 +1,242 @@
|
|||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/spotify_track.dart';
|
||||
|
||||
class AudioService extends ChangeNotifier {
|
||||
static final AudioService _instance = AudioService._internal();
|
||||
factory AudioService() => _instance;
|
||||
AudioService._internal() {
|
||||
_initializePlayer();
|
||||
}
|
||||
|
||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||
|
||||
// Current track state
|
||||
SpotifyTrack? _currentTrack;
|
||||
bool _isPlaying = false;
|
||||
bool _isLoading = false;
|
||||
Duration _currentPosition = Duration.zero;
|
||||
Duration _totalDuration = Duration.zero;
|
||||
|
||||
// Getters
|
||||
SpotifyTrack? get currentTrack => _currentTrack;
|
||||
bool get isPlaying => _isPlaying;
|
||||
bool get isLoading => _isLoading;
|
||||
Duration get currentPosition => _currentPosition;
|
||||
Duration get totalDuration => _totalDuration;
|
||||
double get progress => _totalDuration.inMilliseconds > 0
|
||||
? _currentPosition.inMilliseconds / _totalDuration.inMilliseconds
|
||||
: 0.0;
|
||||
|
||||
// Free audio sources for demo purposes
|
||||
// Using royalty-free music from reliable sources
|
||||
final Map<String, String> _demoAudioUrls = {
|
||||
// Peaceful, lido-appropriate tracks
|
||||
'pop1': 'https://www.bensound.com/bensound-music/bensound-relaxing.mp3',
|
||||
'pop2': 'https://www.bensound.com/bensound-music/bensound-sunny.mp3',
|
||||
'pop3': 'https://www.bensound.com/bensound-music/bensound-jazzcomedy.mp3',
|
||||
'pop4': 'https://www.bensound.com/bensound-music/bensound-acousticbreeze.mp3',
|
||||
'1': 'https://www.bensound.com/bensound-music/bensound-creativeminds.mp3',
|
||||
'2': 'https://www.bensound.com/bensound-music/bensound-happyrock.mp3',
|
||||
'3': 'https://www.bensound.com/bensound-music/bensound-ukulele.mp3',
|
||||
'4': 'https://www.bensound.com/bensound-music/bensound-summer.mp3',
|
||||
'5': 'https://www.bensound.com/bensound-music/bensound-happiness.mp3',
|
||||
};
|
||||
|
||||
void _initializePlayer() {
|
||||
// Listen to player state changes
|
||||
_audioPlayer.onPlayerStateChanged.listen((PlayerState state) {
|
||||
_isPlaying = state == PlayerState.playing;
|
||||
_isLoading = state == PlayerState.stopped && _currentTrack != null;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Listen to position changes
|
||||
_audioPlayer.onPositionChanged.listen((Duration position) {
|
||||
_currentPosition = position;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Listen to duration changes
|
||||
_audioPlayer.onDurationChanged.listen((Duration duration) {
|
||||
_totalDuration = duration;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Listen for track completion
|
||||
_audioPlayer.onPlayerComplete.listen((_) {
|
||||
_onTrackComplete();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> playTrack(SpotifyTrack track) async {
|
||||
try {
|
||||
_isLoading = true;
|
||||
_currentTrack = track;
|
||||
notifyListeners();
|
||||
|
||||
// Check if we have a demo URL for this track
|
||||
String? audioUrl = _demoAudioUrls[track.id];
|
||||
|
||||
if (audioUrl != null) {
|
||||
// Play the demo audio
|
||||
await _audioPlayer.play(UrlSource(audioUrl));
|
||||
print('Playing audio for: ${track.name} by ${track.artistNames}');
|
||||
} else {
|
||||
// For tracks without demo URLs, simulate playback
|
||||
print('Simulating playback for: ${track.name} by ${track.artistNames}');
|
||||
_simulateTrackPlayback(track);
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error playing track: $e');
|
||||
_isLoading = false;
|
||||
// Fallback to simulation
|
||||
_simulateTrackPlayback(track);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _simulateTrackPlayback(SpotifyTrack track) {
|
||||
// Set simulated duration
|
||||
_totalDuration = Duration(milliseconds: track.durationMs);
|
||||
_currentPosition = Duration.zero;
|
||||
_isPlaying = true;
|
||||
|
||||
// Simulate playback progress
|
||||
_startSimulatedProgress();
|
||||
}
|
||||
|
||||
void _startSimulatedProgress() {
|
||||
if (_isPlaying && _currentTrack != null) {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (_isPlaying && _currentTrack != null) {
|
||||
_currentPosition = _currentPosition + const Duration(seconds: 1);
|
||||
|
||||
if (_currentPosition >= _totalDuration) {
|
||||
_onTrackComplete();
|
||||
} else {
|
||||
notifyListeners();
|
||||
_startSimulatedProgress();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> togglePlayPause() async {
|
||||
try {
|
||||
if (_isPlaying) {
|
||||
await _audioPlayer.pause();
|
||||
} else {
|
||||
if (_currentTrack != null) {
|
||||
// Check if we have a real audio URL
|
||||
String? audioUrl = _demoAudioUrls[_currentTrack!.id];
|
||||
if (audioUrl != null) {
|
||||
await _audioPlayer.resume();
|
||||
} else {
|
||||
// Resume simulation
|
||||
_isPlaying = true;
|
||||
_startSimulatedProgress();
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error toggling play/pause: $e');
|
||||
// Fallback to simulation toggle
|
||||
_isPlaying = !_isPlaying;
|
||||
if (_isPlaying) {
|
||||
_startSimulatedProgress();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
try {
|
||||
await _audioPlayer.stop();
|
||||
} catch (e) {
|
||||
print('Error stopping audio: $e');
|
||||
}
|
||||
|
||||
_isPlaying = false;
|
||||
_currentPosition = Duration.zero;
|
||||
_currentTrack = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> seekTo(Duration position) async {
|
||||
try {
|
||||
// Check if we have a real audio URL
|
||||
if (_currentTrack != null && _demoAudioUrls.containsKey(_currentTrack!.id)) {
|
||||
await _audioPlayer.seek(position);
|
||||
} else {
|
||||
// Simulate seeking
|
||||
_currentPosition = position;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error seeking: $e');
|
||||
// Fallback to simulation
|
||||
_currentPosition = position;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _onTrackComplete() {
|
||||
_isPlaying = false;
|
||||
_currentPosition = Duration.zero;
|
||||
notifyListeners();
|
||||
|
||||
// Notify that track is complete (for queue management)
|
||||
onTrackComplete?.call();
|
||||
}
|
||||
|
||||
// Callback for when a track completes
|
||||
Function()? onTrackComplete;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_audioPlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Get formatted time strings
|
||||
String get currentPositionString => _formatDuration(_currentPosition);
|
||||
String get totalDurationString => _formatDuration(_totalDuration);
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$twoDigitMinutes:$twoDigitSeconds';
|
||||
}
|
||||
|
||||
// Add better demo audio URLs (using royalty-free sources)
|
||||
void addDemoAudioUrl(String trackId, String audioUrl) {
|
||||
_demoAudioUrls[trackId] = audioUrl;
|
||||
}
|
||||
|
||||
// Add local asset support
|
||||
Future<void> playAsset(SpotifyTrack track, String assetPath) async {
|
||||
try {
|
||||
_isLoading = true;
|
||||
_currentTrack = track;
|
||||
notifyListeners();
|
||||
|
||||
await _audioPlayer.play(AssetSource(assetPath));
|
||||
print('Playing asset: $assetPath for ${track.name}');
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error playing asset: $e');
|
||||
_isLoading = false;
|
||||
_simulateTrackPlayback(track);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
288
CHALLENGE_2/sleepysound/lib/services/genre_filter_service.dart
Normal file
|
@ -0,0 +1,288 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import '../models/spotify_track.dart';
|
||||
|
||||
class GenreFilterService {
|
||||
// Allowed genres for the Lido atmosphere (chill, ambient, relaxing)
|
||||
static const List<String> allowedGenres = [
|
||||
// Chill and Ambient
|
||||
'chill',
|
||||
'chillout',
|
||||
'ambient',
|
||||
'new age',
|
||||
'meditation',
|
||||
'nature sounds',
|
||||
'spa',
|
||||
'yoga',
|
||||
|
||||
// Smooth genres
|
||||
'smooth jazz',
|
||||
'neo soul',
|
||||
'downtempo',
|
||||
'trip hop',
|
||||
'lo-fi',
|
||||
'lo-fi hip hop',
|
||||
'chillwave',
|
||||
'synthwave',
|
||||
|
||||
// Acoustic and Folk
|
||||
'acoustic',
|
||||
'folk',
|
||||
'indie folk',
|
||||
'singer-songwriter',
|
||||
'soft rock',
|
||||
'alternative',
|
||||
'indie',
|
||||
|
||||
// World and Cultural
|
||||
'world music',
|
||||
'bossa nova',
|
||||
'latin',
|
||||
'reggae',
|
||||
'dub',
|
||||
'tropical',
|
||||
'caribbean',
|
||||
|
||||
// Electronic (chill variants)
|
||||
'house',
|
||||
'deep house',
|
||||
'minimal techno',
|
||||
'ambient techno',
|
||||
'electronica',
|
||||
'minimal',
|
||||
|
||||
// Classical and Instrumental
|
||||
'classical',
|
||||
'instrumental',
|
||||
'piano',
|
||||
'string quartet',
|
||||
'chamber music',
|
||||
'contemporary classical',
|
||||
];
|
||||
|
||||
// Explicitly blocked genres (too energetic/aggressive for Lido)
|
||||
static const List<String> blockedGenres = [
|
||||
'metal',
|
||||
'death metal',
|
||||
'black metal',
|
||||
'hardcore',
|
||||
'punk',
|
||||
'hardcore punk',
|
||||
'grindcore',
|
||||
'screamo',
|
||||
'dubstep',
|
||||
'drum and bass',
|
||||
'breakcore',
|
||||
'speedcore',
|
||||
'gabber',
|
||||
'hardstyle',
|
||||
'hard trance',
|
||||
'psytrance',
|
||||
'hard rock',
|
||||
'thrash',
|
||||
'noise',
|
||||
'industrial',
|
||||
'aggressive',
|
||||
'rap',
|
||||
'hip hop',
|
||||
'trap',
|
||||
'drill',
|
||||
'grime',
|
||||
'gangsta rap',
|
||||
];
|
||||
|
||||
// Keywords that suggest inappropriate content
|
||||
static const List<String> blockedKeywords = [
|
||||
'explicit',
|
||||
'party',
|
||||
'club',
|
||||
'rave',
|
||||
'aggressive',
|
||||
'angry',
|
||||
'violent',
|
||||
'loud',
|
||||
'hardcore',
|
||||
'extreme',
|
||||
'intense',
|
||||
'heavy',
|
||||
'wild',
|
||||
'crazy',
|
||||
'insane',
|
||||
'brutal',
|
||||
'savage',
|
||||
'beast',
|
||||
'fire',
|
||||
'lit',
|
||||
'banger',
|
||||
'drop',
|
||||
'bass drop',
|
||||
'festival',
|
||||
'mosh',
|
||||
'headbang',
|
||||
];
|
||||
|
||||
// Check if a track is appropriate for the Lido atmosphere
|
||||
static bool isTrackAllowed(SpotifyTrack track) {
|
||||
final trackName = track.name.toLowerCase();
|
||||
final artistNames = track.artists.map((a) => a.name.toLowerCase()).join(' ');
|
||||
final albumName = track.album.name.toLowerCase();
|
||||
|
||||
// Check for blocked keywords in track, artist, or album names
|
||||
for (final keyword in blockedKeywords) {
|
||||
if (trackName.contains(keyword) ||
|
||||
artistNames.contains(keyword) ||
|
||||
albumName.contains(keyword)) {
|
||||
if (kDebugMode) {
|
||||
print('Track blocked due to keyword: $keyword');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For now, we'll allow tracks unless they contain blocked keywords
|
||||
// In a real implementation, you'd check against Spotify's genre data
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if a genre is allowed
|
||||
static bool isGenreAllowed(String genre) {
|
||||
final lowerGenre = genre.toLowerCase();
|
||||
|
||||
// Check if explicitly blocked
|
||||
if (blockedGenres.contains(lowerGenre)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if explicitly allowed
|
||||
if (allowedGenres.contains(lowerGenre)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for partial matches in allowed genres
|
||||
for (final allowedGenre in allowedGenres) {
|
||||
if (lowerGenre.contains(allowedGenre) || allowedGenre.contains(lowerGenre)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for partial matches in blocked genres
|
||||
for (final blockedGenre in blockedGenres) {
|
||||
if (lowerGenre.contains(blockedGenre) || blockedGenre.contains(lowerGenre)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to allowed if not explicitly blocked
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get suggested search terms for the atmosphere
|
||||
static List<String> getSuggestedSearchTerms() {
|
||||
return [
|
||||
'chill',
|
||||
'ambient',
|
||||
'acoustic',
|
||||
'coffee shop',
|
||||
'study music',
|
||||
'relaxing',
|
||||
'peaceful',
|
||||
'smooth',
|
||||
'sunset',
|
||||
'ocean',
|
||||
'nature',
|
||||
'meditation',
|
||||
'spa music',
|
||||
'lo-fi',
|
||||
'bossa nova',
|
||||
'jazz',
|
||||
'instrumental',
|
||||
'piano',
|
||||
'guitar',
|
||||
'folk',
|
||||
'indie',
|
||||
'world music',
|
||||
'downtempo',
|
||||
'chillout',
|
||||
'lounge',
|
||||
'soft rock',
|
||||
];
|
||||
}
|
||||
|
||||
// Get genre description for users
|
||||
static String getAtmosphereDescription() {
|
||||
return 'To maintain the peaceful Lido atmosphere, we feature chill, ambient, and relaxing music. Think coffee shop vibes, sunset sounds, and music that enhances tranquility.';
|
||||
}
|
||||
|
||||
// Filter search results based on allowed genres
|
||||
static List<SpotifyTrack> filterSearchResults(List<SpotifyTrack> tracks) {
|
||||
return tracks.where((track) => isTrackAllowed(track)).toList();
|
||||
}
|
||||
|
||||
// Get reason why a track might be rejected
|
||||
static String? getRejectionReason(SpotifyTrack track) {
|
||||
final trackName = track.name.toLowerCase();
|
||||
final artistNames = track.artists.map((a) => a.name.toLowerCase()).join(' ');
|
||||
final albumName = track.album.name.toLowerCase();
|
||||
|
||||
// Check for blocked keywords
|
||||
for (final keyword in blockedKeywords) {
|
||||
if (trackName.contains(keyword) ||
|
||||
artistNames.contains(keyword) ||
|
||||
albumName.contains(keyword)) {
|
||||
return 'This track contains content that might disturb the peaceful Lido atmosphere. Try searching for more chill or ambient music.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if search query suggests inappropriate content
|
||||
static bool isSearchQueryAppropriate(String query) {
|
||||
final lowerQuery = query.toLowerCase();
|
||||
|
||||
for (final keyword in blockedKeywords) {
|
||||
if (lowerQuery.contains(keyword)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (final genre in blockedGenres) {
|
||||
if (lowerQuery.contains(genre)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get alternative search suggestions for inappropriate queries
|
||||
static List<String> getAlternativeSearchSuggestions(String inappropriateQuery) {
|
||||
// Map inappropriate terms to chill alternatives
|
||||
final alternatives = {
|
||||
'party': ['chill', 'lounge', 'relaxing'],
|
||||
'club': ['ambient', 'downtempo', 'smooth'],
|
||||
'rave': ['meditation', 'spa music', 'nature sounds'],
|
||||
'metal': ['acoustic', 'folk', 'classical'],
|
||||
'punk': ['indie', 'alternative', 'soft rock'],
|
||||
'hardcore': ['peaceful', 'calming', 'serene'],
|
||||
'aggressive': ['gentle', 'soothing', 'mellow'],
|
||||
'loud': ['quiet', 'soft', 'whisper'],
|
||||
'heavy': ['light', 'airy', 'floating'],
|
||||
'intense': ['relaxed', 'easy', 'laid-back'],
|
||||
};
|
||||
|
||||
final suggestions = <String>[];
|
||||
final lowerQuery = inappropriateQuery.toLowerCase();
|
||||
|
||||
for (final entry in alternatives.entries) {
|
||||
if (lowerQuery.contains(entry.key)) {
|
||||
suggestions.addAll(entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (suggestions.isEmpty) {
|
||||
suggestions.addAll(['chill', 'ambient', 'relaxing', 'peaceful']);
|
||||
}
|
||||
|
||||
return suggestions.take(3).toList();
|
||||
}
|
||||
}
|
200
CHALLENGE_2/sleepysound/lib/services/music_queue_service.dart
Normal file
|
@ -0,0 +1,200 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import '../models/spotify_track.dart';
|
||||
import '../services/spotify_service.dart';
|
||||
import '../services/audio_service.dart';
|
||||
|
||||
class QueueItem {
|
||||
final SpotifyTrack track;
|
||||
int votes;
|
||||
bool userVoted;
|
||||
final DateTime addedAt;
|
||||
|
||||
QueueItem({
|
||||
required this.track,
|
||||
this.votes = 1,
|
||||
this.userVoted = true,
|
||||
DateTime? addedAt,
|
||||
}) : addedAt = addedAt ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': track.id,
|
||||
'title': track.name,
|
||||
'artist': track.artistNames,
|
||||
'votes': votes,
|
||||
'userVoted': userVoted,
|
||||
'duration': track.duration,
|
||||
'imageUrl': track.imageUrl,
|
||||
'addedAt': addedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
class MusicQueueService extends ChangeNotifier {
|
||||
static final MusicQueueService _instance = MusicQueueService._internal();
|
||||
factory MusicQueueService() => _instance;
|
||||
MusicQueueService._internal();
|
||||
|
||||
final SpotifyService _spotifyService = SpotifyService();
|
||||
final AudioService _audioService = AudioService();
|
||||
|
||||
// Current playing track
|
||||
SpotifyTrack? _currentTrack;
|
||||
|
||||
// Queue management
|
||||
final List<QueueItem> _queue = [];
|
||||
|
||||
// Recently played
|
||||
final List<SpotifyTrack> _recentlyPlayed = [];
|
||||
|
||||
// Getters
|
||||
SpotifyTrack? get currentTrack => _audioService.currentTrack ?? _currentTrack;
|
||||
bool get isPlaying => _audioService.isPlaying;
|
||||
double get progress => _audioService.progress;
|
||||
List<QueueItem> get queue => List.unmodifiable(_queue);
|
||||
List<SpotifyTrack> get recentlyPlayed => List.unmodifiable(_recentlyPlayed);
|
||||
|
||||
// Queue operations
|
||||
void addToQueue(SpotifyTrack track) {
|
||||
// Check if track is already in queue
|
||||
final existingIndex = _queue.indexWhere((item) => item.track.id == track.id);
|
||||
|
||||
if (existingIndex != -1) {
|
||||
// If track exists, upvote it
|
||||
upvote(existingIndex);
|
||||
} else {
|
||||
// Add new track to queue
|
||||
final queueItem = QueueItem(track: track);
|
||||
_queue.add(queueItem);
|
||||
_sortQueue();
|
||||
notifyListeners();
|
||||
print('Added "${track.name}" by ${track.artistNames} to queue');
|
||||
}
|
||||
}
|
||||
|
||||
void upvote(int index) {
|
||||
if (index >= 0 && index < _queue.length) {
|
||||
_queue[index].votes++;
|
||||
if (!_queue[index].userVoted) {
|
||||
_queue[index].userVoted = true;
|
||||
}
|
||||
_sortQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void downvote(int index) {
|
||||
if (index >= 0 && index < _queue.length) {
|
||||
if (_queue[index].votes > 0) {
|
||||
_queue[index].votes--;
|
||||
}
|
||||
_sortQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _sortQueue() {
|
||||
_queue.sort((a, b) {
|
||||
// First sort by votes (descending)
|
||||
final voteComparison = b.votes.compareTo(a.votes);
|
||||
if (voteComparison != 0) return voteComparison;
|
||||
|
||||
// If votes are equal, sort by time added (ascending - first come first serve)
|
||||
return a.addedAt.compareTo(b.addedAt);
|
||||
});
|
||||
}
|
||||
|
||||
// Playback control
|
||||
Future<void> playNext() async {
|
||||
if (_queue.isNotEmpty) {
|
||||
final nextItem = _queue.removeAt(0);
|
||||
_currentTrack = nextItem.track;
|
||||
|
||||
// Use audio service to actually play the track
|
||||
await _audioService.playTrack(nextItem.track);
|
||||
|
||||
// Add to recently played
|
||||
_recentlyPlayed.insert(0, nextItem.track);
|
||||
if (_recentlyPlayed.length > 10) {
|
||||
_recentlyPlayed.removeLast();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
print('Now playing: ${_currentTrack!.name} by ${_currentTrack!.artistNames}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> togglePlayPause() async {
|
||||
await _audioService.togglePlayPause();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> skipTrack() async {
|
||||
await playNext();
|
||||
}
|
||||
|
||||
Future<void> seekTo(double position) async {
|
||||
if (_audioService.totalDuration != Duration.zero) {
|
||||
final seekPosition = Duration(
|
||||
milliseconds: (position * _audioService.totalDuration.inMilliseconds).round(),
|
||||
);
|
||||
await _audioService.seekTo(seekPosition);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize with some popular tracks
|
||||
Future<void> initializeQueue() async {
|
||||
if (_queue.isEmpty && _currentTrack == null) {
|
||||
try {
|
||||
final popularTracks = await _spotifyService.getPopularTracks();
|
||||
for (final track in popularTracks.take(4)) {
|
||||
final queueItem = QueueItem(
|
||||
track: track,
|
||||
votes: 10 - popularTracks.indexOf(track) * 2, // Decreasing votes
|
||||
userVoted: false,
|
||||
);
|
||||
_queue.add(queueItem);
|
||||
}
|
||||
|
||||
// Set up audio service callback for track completion
|
||||
_audioService.onTrackComplete = () {
|
||||
playNext();
|
||||
};
|
||||
|
||||
// Start playing the first track
|
||||
if (_queue.isNotEmpty) {
|
||||
await playNext();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error initializing queue: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
Future<List<SpotifyTrack>> searchTracks(String query) async {
|
||||
return await _spotifyService.searchTracks(query, limit: 20);
|
||||
}
|
||||
|
||||
// Get queue as JSON for display
|
||||
List<Map<String, dynamic>> get queueAsJson {
|
||||
return _queue.map((item) => item.toJson()).toList();
|
||||
}
|
||||
|
||||
// Get current track info for display
|
||||
Map<String, dynamic>? get currentTrackInfo {
|
||||
final track = currentTrack;
|
||||
if (track == null) return null;
|
||||
|
||||
return {
|
||||
'title': track.name,
|
||||
'artist': track.artistNames,
|
||||
'album': track.album.name,
|
||||
'imageUrl': track.imageUrl,
|
||||
'duration': _audioService.totalDurationString,
|
||||
'currentTime': _audioService.currentPositionString,
|
||||
'progress': progress,
|
||||
'isPlaying': isPlaying,
|
||||
};
|
||||
}
|
||||
}
|
608
CHALLENGE_2/sleepysound/lib/services/network_group_service.dart
Normal file
|
@ -0,0 +1,608 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:network_info_plus/network_info_plus.dart';
|
||||
import 'package:multicast_dns/multicast_dns.dart';
|
||||
import 'music_queue_service.dart';
|
||||
|
||||
class NetworkUser {
|
||||
final String id;
|
||||
final String name;
|
||||
final String ipAddress;
|
||||
final DateTime joinedAt;
|
||||
final int votes;
|
||||
bool isOnline;
|
||||
DateTime lastSeen;
|
||||
String? currentTrackId;
|
||||
String? currentTrackName;
|
||||
String? currentArtist;
|
||||
String? currentTrackImage;
|
||||
bool isListening;
|
||||
|
||||
NetworkUser({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.ipAddress,
|
||||
required this.joinedAt,
|
||||
this.votes = 0,
|
||||
this.isOnline = true,
|
||||
DateTime? lastSeen,
|
||||
this.currentTrackId,
|
||||
this.currentTrackName,
|
||||
this.currentArtist,
|
||||
this.currentTrackImage,
|
||||
this.isListening = false,
|
||||
}) : lastSeen = lastSeen ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'ipAddress': ipAddress,
|
||||
'joinedAt': joinedAt.toIso8601String(),
|
||||
'votes': votes,
|
||||
'isOnline': isOnline,
|
||||
'lastSeen': lastSeen.toIso8601String(),
|
||||
'currentTrackId': currentTrackId,
|
||||
'currentTrackName': currentTrackName,
|
||||
'currentArtist': currentArtist,
|
||||
'currentTrackImage': currentTrackImage,
|
||||
'isListening': isListening,
|
||||
};
|
||||
|
||||
factory NetworkUser.fromJson(Map<String, dynamic> json) => NetworkUser(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
ipAddress: json['ipAddress'],
|
||||
joinedAt: DateTime.parse(json['joinedAt']),
|
||||
votes: json['votes'] ?? 0,
|
||||
isOnline: json['isOnline'] ?? true,
|
||||
lastSeen: DateTime.parse(json['lastSeen']),
|
||||
currentTrackId: json['currentTrackId'],
|
||||
currentTrackName: json['currentTrackName'],
|
||||
currentArtist: json['currentArtist'],
|
||||
currentTrackImage: json['currentTrackImage'],
|
||||
isListening: json['isListening'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
class NetworkGroupService extends ChangeNotifier {
|
||||
static const int _discoveryPort = 8089;
|
||||
static const int _heartbeatInterval = 10; // seconds
|
||||
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
final NetworkInfo _networkInfo = NetworkInfo();
|
||||
MDnsClient? _mdnsClient;
|
||||
HttpServer? _httpServer;
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _discoveryTimer;
|
||||
|
||||
bool _isConnectedToWifi = false;
|
||||
String _currentNetworkName = '';
|
||||
String _currentNetworkSSID = '';
|
||||
String _localIpAddress = '';
|
||||
String _networkSubnet = '';
|
||||
|
||||
final Map<String, NetworkUser> _networkUsers = {};
|
||||
late NetworkUser _currentUser;
|
||||
MusicQueueService? _musicService;
|
||||
|
||||
// Getters
|
||||
bool get isConnectedToWifi => _isConnectedToWifi;
|
||||
String get currentNetworkName => _currentNetworkName;
|
||||
String get currentNetworkSSID => _currentNetworkSSID;
|
||||
String get localIpAddress => _localIpAddress;
|
||||
List<NetworkUser> get networkUsers => _networkUsers.values.toList();
|
||||
NetworkUser get currentUser => _currentUser;
|
||||
int get onlineUsersCount => _networkUsers.values.where((u) => u.isOnline).length;
|
||||
|
||||
NetworkGroupService() {
|
||||
_initializeCurrentUser();
|
||||
_startNetworkMonitoring();
|
||||
// Initialize music service reference
|
||||
_musicService = MusicQueueService();
|
||||
}
|
||||
|
||||
void _initializeCurrentUser() {
|
||||
final random = Random();
|
||||
final guestNames = ['Alex', 'Sarah', 'Marco', 'Lisa', 'Tom', 'Anna', 'David', 'Emma'];
|
||||
final randomName = guestNames[random.nextInt(guestNames.length)];
|
||||
final randomId = random.nextInt(999);
|
||||
|
||||
_currentUser = NetworkUser(
|
||||
id: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: '$randomName #$randomId',
|
||||
ipAddress: _localIpAddress,
|
||||
joinedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startNetworkMonitoring() async {
|
||||
// Monitor connectivity changes
|
||||
_connectivity.onConnectivityChanged.listen(_onConnectivityChanged);
|
||||
|
||||
// Initial connectivity check
|
||||
await _checkConnectivity();
|
||||
}
|
||||
|
||||
Future<void> _onConnectivityChanged(List<ConnectivityResult> results) async {
|
||||
await _checkConnectivity();
|
||||
}
|
||||
|
||||
Future<void> _checkConnectivity() async {
|
||||
final connectivityResult = await _connectivity.checkConnectivity();
|
||||
final wasConnected = _isConnectedToWifi;
|
||||
|
||||
_isConnectedToWifi = connectivityResult.contains(ConnectivityResult.wifi);
|
||||
|
||||
if (_isConnectedToWifi) {
|
||||
await _getNetworkInfo();
|
||||
if (!wasConnected) {
|
||||
await _startNetworkServices();
|
||||
}
|
||||
} else {
|
||||
if (wasConnected) {
|
||||
await _stopNetworkServices();
|
||||
}
|
||||
_clearNetworkInfo();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _getNetworkInfo() async {
|
||||
try {
|
||||
_currentNetworkSSID = await _networkInfo.getWifiName() ?? 'Unknown Network';
|
||||
_currentNetworkName = _currentNetworkSSID.replaceAll('"', ''); // Remove quotes
|
||||
_localIpAddress = await _networkInfo.getWifiIP() ?? '';
|
||||
|
||||
// Calculate network subnet (assuming /24)
|
||||
if (_localIpAddress.isNotEmpty) {
|
||||
final parts = _localIpAddress.split('.');
|
||||
if (parts.length == 4) {
|
||||
_networkSubnet = '${parts[0]}.${parts[1]}.${parts[2]}';
|
||||
}
|
||||
}
|
||||
|
||||
// Update current user's IP
|
||||
_currentUser = NetworkUser(
|
||||
id: _currentUser.id,
|
||||
name: _currentUser.name,
|
||||
ipAddress: _localIpAddress,
|
||||
joinedAt: _currentUser.joinedAt,
|
||||
votes: _currentUser.votes,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error getting network info: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _clearNetworkInfo() {
|
||||
_currentNetworkName = '';
|
||||
_currentNetworkSSID = '';
|
||||
_localIpAddress = '';
|
||||
_networkSubnet = '';
|
||||
_networkUsers.clear();
|
||||
}
|
||||
|
||||
Future<void> _startNetworkServices() async {
|
||||
if (!_isConnectedToWifi || _localIpAddress.isEmpty) return;
|
||||
|
||||
try {
|
||||
// Start mDNS client for service discovery
|
||||
_mdnsClient = MDnsClient();
|
||||
await _mdnsClient!.start();
|
||||
|
||||
// Start HTTP server for peer communication
|
||||
await _startHttpServer();
|
||||
|
||||
// Start announcing our service
|
||||
await _announceService();
|
||||
|
||||
// Start discovering other users
|
||||
_startDiscovery();
|
||||
|
||||
// Start heartbeat for keeping users online
|
||||
_startHeartbeat();
|
||||
|
||||
// Add ourselves to the users list
|
||||
_networkUsers[_currentUser.id] = _currentUser;
|
||||
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error starting network services: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopNetworkServices() async {
|
||||
// Stop timers
|
||||
_heartbeatTimer?.cancel();
|
||||
_discoveryTimer?.cancel();
|
||||
|
||||
// Stop HTTP server
|
||||
await _httpServer?.close();
|
||||
_httpServer = null;
|
||||
|
||||
// Stop mDNS client
|
||||
_mdnsClient?.stop();
|
||||
_mdnsClient = null;
|
||||
|
||||
// Clear users
|
||||
_networkUsers.clear();
|
||||
}
|
||||
|
||||
Future<void> _startHttpServer() async {
|
||||
try {
|
||||
_httpServer = await HttpServer.bind(InternetAddress.anyIPv4, _discoveryPort);
|
||||
_httpServer!.listen((request) async {
|
||||
await _handleHttpRequest(request);
|
||||
});
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error starting HTTP server: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleHttpRequest(HttpRequest request) async {
|
||||
try {
|
||||
final response = request.response;
|
||||
response.headers.set('Content-Type', 'application/json');
|
||||
response.headers.set('Access-Control-Allow-Origin', '*');
|
||||
|
||||
if (request.method == 'GET' && request.uri.path == '/user') {
|
||||
// Update current user with latest listening info before sending
|
||||
await _updateCurrentUserListeningInfo();
|
||||
// Return current user info
|
||||
response.write(jsonEncode(_currentUser.toJson()));
|
||||
} else if (request.method == 'POST' && request.uri.path == '/heartbeat') {
|
||||
// Handle heartbeat from other users
|
||||
final body = await utf8.decoder.bind(request).join();
|
||||
final userData = jsonDecode(body);
|
||||
final user = NetworkUser.fromJson(userData);
|
||||
|
||||
_networkUsers[user.id] = user;
|
||||
notifyListeners();
|
||||
|
||||
response.write(jsonEncode({'status': 'ok'}));
|
||||
} else if (request.method == 'POST' && request.uri.path == '/join-session') {
|
||||
// Handle request to join this user's listening session
|
||||
final body = await utf8.decoder.bind(request).join();
|
||||
// Parse request data for future use (logging, analytics, etc.)
|
||||
jsonDecode(body);
|
||||
|
||||
// Get current track info to send back
|
||||
final currentTrackInfo = _musicService?.currentTrackInfo;
|
||||
if (currentTrackInfo != null) {
|
||||
response.write(jsonEncode({
|
||||
'status': 'ok',
|
||||
'trackInfo': currentTrackInfo,
|
||||
'message': 'Successfully joined listening session'
|
||||
}));
|
||||
} else {
|
||||
response.write(jsonEncode({
|
||||
'status': 'no_track',
|
||||
'message': 'No track currently playing'
|
||||
}));
|
||||
}
|
||||
} else if (request.method == 'GET' && request.uri.path == '/current-track') {
|
||||
// Get current track info without joining
|
||||
await _updateCurrentUserListeningInfo();
|
||||
final currentTrackInfo = _musicService?.currentTrackInfo;
|
||||
if (currentTrackInfo != null) {
|
||||
response.write(jsonEncode({
|
||||
'status': 'ok',
|
||||
'trackInfo': currentTrackInfo
|
||||
}));
|
||||
} else {
|
||||
response.write(jsonEncode({
|
||||
'status': 'no_track',
|
||||
'message': 'No track currently playing'
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
response.statusCode = 404;
|
||||
response.write(jsonEncode({'error': 'Not found'}));
|
||||
}
|
||||
|
||||
await response.close();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error handling HTTP request: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _announceService() async {
|
||||
if (_mdnsClient == null || _localIpAddress.isEmpty) return;
|
||||
|
||||
try {
|
||||
// This would announce our service via mDNS
|
||||
// In a real implementation, you'd use proper mDNS announcements
|
||||
if (kDebugMode) {
|
||||
print('Announcing service on $_localIpAddress:$_discoveryPort');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error announcing service: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startDiscovery() {
|
||||
_discoveryTimer = Timer.periodic(const Duration(seconds: 15), (_) async {
|
||||
await _discoverUsers();
|
||||
});
|
||||
|
||||
// Initial discovery
|
||||
_discoverUsers();
|
||||
}
|
||||
|
||||
Future<void> _discoverUsers() async {
|
||||
if (_networkSubnet.isEmpty) return;
|
||||
|
||||
// Scan local network for other SleepySound users
|
||||
final futures = <Future>[];
|
||||
|
||||
for (int i = 1; i <= 254; i++) {
|
||||
final ip = '$_networkSubnet.$i';
|
||||
if (ip != _localIpAddress) {
|
||||
futures.add(_tryConnectToUser(ip));
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all connection attempts (with timeout)
|
||||
await Future.wait(futures).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () => [],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _tryConnectToUser(String ip) async {
|
||||
try {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 2);
|
||||
|
||||
final request = await client.getUrl(Uri.parse('http://$ip:$_discoveryPort/user'));
|
||||
final response = await request.close().timeout(const Duration(seconds: 2));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = await utf8.decoder.bind(response).join();
|
||||
final userData = jsonDecode(body);
|
||||
final user = NetworkUser.fromJson(userData);
|
||||
|
||||
if (user.id != _currentUser.id) {
|
||||
_networkUsers[user.id] = user;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
client.close();
|
||||
} catch (e) {
|
||||
// Ignore connection errors (expected for non-SleepySound devices)
|
||||
}
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: _heartbeatInterval), (_) async {
|
||||
await _sendHeartbeat();
|
||||
_cleanupOfflineUsers();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendHeartbeat() async {
|
||||
final heartbeatData = jsonEncode(_currentUser.toJson());
|
||||
|
||||
// Send heartbeat to all known users
|
||||
final futures = _networkUsers.values
|
||||
.where((user) => user.id != _currentUser.id && user.isOnline)
|
||||
.map((user) => _sendHeartbeatToUser(user.ipAddress, heartbeatData));
|
||||
|
||||
await Future.wait(futures);
|
||||
}
|
||||
|
||||
Future<void> _sendHeartbeatToUser(String ip, String data) async {
|
||||
try {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 2);
|
||||
|
||||
final request = await client.postUrl(Uri.parse('http://$ip:$_discoveryPort/heartbeat'));
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.write(data);
|
||||
|
||||
await request.close().timeout(const Duration(seconds: 2));
|
||||
client.close();
|
||||
} catch (e) {
|
||||
// Mark user as potentially offline
|
||||
final user = _networkUsers.values.firstWhere(
|
||||
(u) => u.ipAddress == ip,
|
||||
orElse: () => NetworkUser(id: '', name: '', ipAddress: '', joinedAt: DateTime.now()),
|
||||
);
|
||||
if (user.id.isNotEmpty) {
|
||||
user.isOnline = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _cleanupOfflineUsers() {
|
||||
final now = DateTime.now();
|
||||
final usersToRemove = <String>[];
|
||||
|
||||
for (final user in _networkUsers.values) {
|
||||
if (user.id != _currentUser.id) {
|
||||
final timeSinceLastSeen = now.difference(user.lastSeen).inSeconds;
|
||||
if (timeSinceLastSeen > _heartbeatInterval * 3) {
|
||||
user.isOnline = false;
|
||||
// Remove users that have been offline for more than 5 minutes
|
||||
if (timeSinceLastSeen > 300) {
|
||||
usersToRemove.add(user.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final userId in usersToRemove) {
|
||||
_networkUsers.remove(userId);
|
||||
}
|
||||
|
||||
if (usersToRemove.isNotEmpty) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateCurrentUserListeningInfo() async {
|
||||
final currentTrackInfo = _musicService?.currentTrackInfo;
|
||||
final currentTrack = _musicService?.currentTrack;
|
||||
|
||||
if (currentTrack != null && currentTrackInfo != null) {
|
||||
_currentUser = NetworkUser(
|
||||
id: _currentUser.id,
|
||||
name: _currentUser.name,
|
||||
ipAddress: _currentUser.ipAddress,
|
||||
joinedAt: _currentUser.joinedAt,
|
||||
votes: _currentUser.votes,
|
||||
isOnline: true,
|
||||
currentTrackId: currentTrack.id,
|
||||
currentTrackName: currentTrack.name,
|
||||
currentArtist: currentTrack.artistNames,
|
||||
currentTrackImage: currentTrack.imageUrl,
|
||||
isListening: currentTrackInfo['isPlaying'] ?? false,
|
||||
);
|
||||
} else {
|
||||
_currentUser = NetworkUser(
|
||||
id: _currentUser.id,
|
||||
name: _currentUser.name,
|
||||
ipAddress: _currentUser.ipAddress,
|
||||
joinedAt: _currentUser.joinedAt,
|
||||
votes: _currentUser.votes,
|
||||
isOnline: true,
|
||||
isListening: false,
|
||||
);
|
||||
}
|
||||
|
||||
_networkUsers[_currentUser.id] = _currentUser;
|
||||
}
|
||||
|
||||
// Public methods for UI interaction
|
||||
Future<void> refreshNetwork() async {
|
||||
await _checkConnectivity();
|
||||
if (_isConnectedToWifi) {
|
||||
await _discoverUsers();
|
||||
}
|
||||
}
|
||||
|
||||
void updateUserVotes(int votes) {
|
||||
_currentUser = NetworkUser(
|
||||
id: _currentUser.id,
|
||||
name: _currentUser.name,
|
||||
ipAddress: _currentUser.ipAddress,
|
||||
joinedAt: _currentUser.joinedAt,
|
||||
votes: votes,
|
||||
isOnline: true,
|
||||
);
|
||||
_networkUsers[_currentUser.id] = _currentUser;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String getConnectionStatus() {
|
||||
if (!_isConnectedToWifi) {
|
||||
return 'Not connected to WiFi';
|
||||
}
|
||||
if (_currentNetworkName.isEmpty) {
|
||||
return 'Connected to WiFi';
|
||||
}
|
||||
return 'Connected to $_currentNetworkName';
|
||||
}
|
||||
|
||||
// Join another user's listening session
|
||||
Future<bool> joinListeningSession(NetworkUser user) async {
|
||||
if (!user.isListening || user.currentTrackId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 5);
|
||||
|
||||
final request = await client.postUrl(
|
||||
Uri.parse('http://${user.ipAddress}:$_discoveryPort/join-session')
|
||||
);
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.write(jsonEncode({'userId': _currentUser.id}));
|
||||
|
||||
final response = await request.close().timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = await utf8.decoder.bind(response).join();
|
||||
final responseData = jsonDecode(body);
|
||||
|
||||
if (responseData['status'] == 'ok' && responseData['trackInfo'] != null) {
|
||||
// Here you could sync the track with your local player
|
||||
// For now, we'll just return success
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
client.close();
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error joining listening session: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Demo methods for testing
|
||||
void simulateNetworkConnection() {
|
||||
_isConnectedToWifi = true;
|
||||
_currentNetworkName = 'Demo WiFi Network';
|
||||
_currentNetworkSSID = '"Demo WiFi Network"';
|
||||
_localIpAddress = '192.168.1.100';
|
||||
_networkSubnet = '192.168.1';
|
||||
|
||||
// Update current user with new IP
|
||||
_currentUser = NetworkUser(
|
||||
id: _currentUser.id,
|
||||
name: _currentUser.name,
|
||||
ipAddress: _localIpAddress,
|
||||
joinedAt: _currentUser.joinedAt,
|
||||
votes: _currentUser.votes,
|
||||
);
|
||||
_networkUsers[_currentUser.id] = _currentUser;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearDemoUsers() {
|
||||
_networkUsers.clear();
|
||||
_networkUsers[_currentUser.id] = _currentUser;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addDemoUser(String name, int votes) {
|
||||
final user = NetworkUser(
|
||||
id: 'demo_${DateTime.now().millisecondsSinceEpoch}_${name.hashCode}',
|
||||
name: name,
|
||||
ipAddress: '192.168.1.${101 + _networkUsers.length}',
|
||||
joinedAt: DateTime.now().subtract(Duration(minutes: (votes * 2))),
|
||||
votes: votes,
|
||||
isOnline: true,
|
||||
);
|
||||
|
||||
_networkUsers[user.id] = user;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopNetworkServices();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,255 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SpamProtectionService extends ChangeNotifier {
|
||||
// Vote limits per user
|
||||
static const int maxVotesPerHour = 20;
|
||||
static const int maxVotesPerMinute = 5;
|
||||
static const int maxSuggestionsPerHour = 10;
|
||||
static const int maxSuggestionsPerMinute = 3;
|
||||
|
||||
// Cooldown periods (in seconds)
|
||||
static const int voteCooldown = 2;
|
||||
static const int suggestionCooldown = 10;
|
||||
|
||||
// User activity tracking
|
||||
final Map<String, List<DateTime>> _userVotes = {};
|
||||
final Map<String, List<DateTime>> _userSuggestions = {};
|
||||
final Map<String, DateTime> _lastVoteTime = {};
|
||||
final Map<String, DateTime> _lastSuggestionTime = {};
|
||||
final Map<String, int> _consecutiveActions = {};
|
||||
|
||||
// Blocked users (temporary)
|
||||
final Map<String, DateTime> _blockedUsers = {};
|
||||
|
||||
String getCurrentUserId() {
|
||||
// In a real app, this would come from authentication
|
||||
return 'current_user_${DateTime.now().millisecondsSinceEpoch ~/ 1000000}';
|
||||
}
|
||||
|
||||
// Check if user can vote
|
||||
bool canVote(String userId) {
|
||||
// Check if user is temporarily blocked
|
||||
if (_isUserBlocked(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow first vote without cooldown for smooth user experience
|
||||
final votes = _userVotes[userId] ?? [];
|
||||
if (votes.isEmpty) {
|
||||
return true; // First vote is always allowed
|
||||
}
|
||||
|
||||
// Check cooldown for subsequent votes
|
||||
if (_isOnCooldown(userId, _lastVoteTime, voteCooldown)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check rate limits
|
||||
if (!_checkRateLimit(userId, _userVotes, maxVotesPerMinute, maxVotesPerHour)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user can suggest songs
|
||||
bool canSuggest(String userId) {
|
||||
// Check if user is temporarily blocked
|
||||
if (_isUserBlocked(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow first suggestion without cooldown for smooth user experience
|
||||
final suggestions = _userSuggestions[userId] ?? [];
|
||||
if (suggestions.isEmpty) {
|
||||
return true; // First suggestion is always allowed
|
||||
}
|
||||
|
||||
// Check cooldown for subsequent suggestions
|
||||
if (_isOnCooldown(userId, _lastSuggestionTime, suggestionCooldown)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check rate limits
|
||||
if (!_checkRateLimit(userId, _userSuggestions, maxSuggestionsPerMinute, maxSuggestionsPerHour)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Record a vote action
|
||||
void recordVote(String userId) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Add to vote history
|
||||
_userVotes.putIfAbsent(userId, () => []).add(now);
|
||||
_lastVoteTime[userId] = now;
|
||||
|
||||
// Track consecutive actions for spam detection
|
||||
_incrementConsecutiveActions(userId);
|
||||
|
||||
// Clean old entries
|
||||
_cleanOldEntries(_userVotes[userId]!);
|
||||
|
||||
// Check for suspicious behavior
|
||||
_checkForSpam(userId);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Record a suggestion action
|
||||
void recordSuggestion(String userId) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Add to suggestion history
|
||||
_userSuggestions.putIfAbsent(userId, () => []).add(now);
|
||||
_lastSuggestionTime[userId] = now;
|
||||
|
||||
// Track consecutive actions for spam detection
|
||||
_incrementConsecutiveActions(userId);
|
||||
|
||||
// Clean old entries
|
||||
_cleanOldEntries(_userSuggestions[userId]!);
|
||||
|
||||
// Check for suspicious behavior
|
||||
_checkForSpam(userId);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Get remaining cooldown time in seconds
|
||||
int getVoteCooldownRemaining(String userId) {
|
||||
final lastVote = _lastVoteTime[userId];
|
||||
if (lastVote == null) return 0;
|
||||
|
||||
final elapsed = DateTime.now().difference(lastVote).inSeconds;
|
||||
return (voteCooldown - elapsed).clamp(0, voteCooldown);
|
||||
}
|
||||
|
||||
int getSuggestionCooldownRemaining(String userId) {
|
||||
final lastSuggestion = _lastSuggestionTime[userId];
|
||||
if (lastSuggestion == null) return 0;
|
||||
|
||||
final elapsed = DateTime.now().difference(lastSuggestion).inSeconds;
|
||||
return (suggestionCooldown - elapsed).clamp(0, suggestionCooldown);
|
||||
}
|
||||
|
||||
// Get user activity stats
|
||||
Map<String, int> getUserStats(String userId) {
|
||||
final now = DateTime.now();
|
||||
final hourAgo = now.subtract(const Duration(hours: 1));
|
||||
final minuteAgo = now.subtract(const Duration(minutes: 1));
|
||||
|
||||
final votesThisHour = _userVotes[userId]?.where((time) => time.isAfter(hourAgo)).length ?? 0;
|
||||
final votesThisMinute = _userVotes[userId]?.where((time) => time.isAfter(minuteAgo)).length ?? 0;
|
||||
final suggestionsThisHour = _userSuggestions[userId]?.where((time) => time.isAfter(hourAgo)).length ?? 0;
|
||||
final suggestionsThisMinute = _userSuggestions[userId]?.where((time) => time.isAfter(minuteAgo)).length ?? 0;
|
||||
|
||||
return {
|
||||
'votesThisHour': votesThisHour,
|
||||
'votesThisMinute': votesThisMinute,
|
||||
'suggestionsThisHour': suggestionsThisHour,
|
||||
'suggestionsThisMinute': suggestionsThisMinute,
|
||||
'maxVotesPerHour': maxVotesPerHour,
|
||||
'maxVotesPerMinute': maxVotesPerMinute,
|
||||
'maxSuggestionsPerHour': maxSuggestionsPerHour,
|
||||
'maxSuggestionsPerMinute': maxSuggestionsPerMinute,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is blocked
|
||||
bool _isUserBlocked(String userId) {
|
||||
final blockTime = _blockedUsers[userId];
|
||||
if (blockTime == null) return false;
|
||||
|
||||
// Unblock after 5 minutes
|
||||
if (DateTime.now().difference(blockTime).inMinutes >= 5) {
|
||||
_blockedUsers.remove(userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
bool _isOnCooldown(String userId, Map<String, DateTime> lastActionTime, int cooldownSeconds) {
|
||||
final lastAction = lastActionTime[userId];
|
||||
if (lastAction == null) return false;
|
||||
|
||||
return DateTime.now().difference(lastAction).inSeconds < cooldownSeconds;
|
||||
}
|
||||
|
||||
// Check rate limits
|
||||
bool _checkRateLimit(String userId, Map<String, List<DateTime>> userActions, int maxPerMinute, int maxPerHour) {
|
||||
final actions = userActions[userId] ?? [];
|
||||
final now = DateTime.now();
|
||||
|
||||
// Count actions in the last minute
|
||||
final actionsLastMinute = actions.where((time) =>
|
||||
now.difference(time).inMinutes < 1).length;
|
||||
if (actionsLastMinute >= maxPerMinute) return false;
|
||||
|
||||
// Count actions in the last hour
|
||||
final actionsLastHour = actions.where((time) =>
|
||||
now.difference(time).inHours < 1).length;
|
||||
if (actionsLastHour >= maxPerHour) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clean old entries (older than 1 hour)
|
||||
void _cleanOldEntries(List<DateTime> entries) {
|
||||
final oneHourAgo = DateTime.now().subtract(const Duration(hours: 1));
|
||||
entries.removeWhere((time) => time.isBefore(oneHourAgo));
|
||||
}
|
||||
|
||||
// Track consecutive actions for spam detection
|
||||
void _incrementConsecutiveActions(String userId) {
|
||||
_consecutiveActions[userId] = (_consecutiveActions[userId] ?? 0) + 1;
|
||||
|
||||
// Reset after some time of inactivity
|
||||
Timer(const Duration(seconds: 30), () {
|
||||
_consecutiveActions[userId] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Check for spam behavior and block if necessary
|
||||
void _checkForSpam(String userId) {
|
||||
final consecutive = _consecutiveActions[userId] ?? 0;
|
||||
|
||||
// Block user if too many consecutive actions
|
||||
if (consecutive > 15) {
|
||||
_blockedUsers[userId] = DateTime.now();
|
||||
if (kDebugMode) {
|
||||
print('User $userId temporarily blocked for spam behavior');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get block status message
|
||||
String? getBlockMessage(String userId) {
|
||||
final blockTime = _blockedUsers[userId];
|
||||
if (blockTime == null) return null;
|
||||
|
||||
final remaining = 5 - DateTime.now().difference(blockTime).inMinutes;
|
||||
if (remaining <= 0) {
|
||||
_blockedUsers.remove(userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'You are temporarily blocked for $remaining more minutes due to excessive activity.';
|
||||
}
|
||||
|
||||
// Clear all data (for testing)
|
||||
void clearAllData() {
|
||||
_userVotes.clear();
|
||||
_userSuggestions.clear();
|
||||
_lastVoteTime.clear();
|
||||
_lastSuggestionTime.clear();
|
||||
_consecutiveActions.clear();
|
||||
_blockedUsers.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
224
CHALLENGE_2/sleepysound/lib/services/spotify_service.dart
Normal file
|
@ -0,0 +1,224 @@
|
|||
// Spotify API Credentials
|
||||
//
|
||||
// SETUP INSTRUCTIONS:
|
||||
// 1. Go to https://developer.spotify.com/dashboard
|
||||
// 2. Log in with your Spotify account
|
||||
// 3. Create a new app called "SleepySound"
|
||||
// 4. Copy your Client ID and Client Secret below
|
||||
// 5. Save this file
|
||||
//
|
||||
// SECURITY NOTE: Never commit real credentials to version control!
|
||||
// For production, use environment variables or secure storage.
|
||||
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/spotify_track.dart';
|
||||
import 'SPOTIFY_SECRET.dart';
|
||||
|
||||
class SpotifyService {
|
||||
// Load credentials from the secret file
|
||||
static String get _clientId => SpotifyCredentials.clientId;
|
||||
static String get _clientSecret => SpotifyCredentials.clientSecret;
|
||||
static const String _baseUrl = 'https://api.spotify.com/v1';
|
||||
static const String _authUrl = 'https://accounts.spotify.com/api/token';
|
||||
|
||||
String? _accessToken;
|
||||
|
||||
// Check if valid credentials are provided
|
||||
bool get _hasValidCredentials =>
|
||||
_clientId != 'YOUR_SPOTIFY_CLIENT_ID' &&
|
||||
_clientSecret != 'YOUR_SPOTIFY_CLIENT_SECRET' &&
|
||||
_clientId.isNotEmpty &&
|
||||
_clientSecret.isNotEmpty;
|
||||
|
||||
// For demo purposes, we'll use Client Credentials flow (no user login required)
|
||||
// In a real app, you'd want to implement Authorization Code flow for user-specific features
|
||||
|
||||
Future<void> _getAccessToken() async {
|
||||
// Check if we have valid credentials first
|
||||
if (!_hasValidCredentials) {
|
||||
print('No valid Spotify credentials found. Using demo data.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse(_authUrl),
|
||||
headers: {
|
||||
'Authorization': 'Basic ${base64Encode(utf8.encode('$_clientId:$_clientSecret'))}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'grant_type=client_credentials',
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
_accessToken = data['access_token'];
|
||||
|
||||
// Save token to shared preferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('spotify_access_token', _accessToken!);
|
||||
|
||||
print('Spotify access token obtained successfully');
|
||||
} else {
|
||||
print('Failed to get Spotify access token: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting Spotify access token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureValidToken() async {
|
||||
// If no valid credentials, skip token generation
|
||||
if (!_hasValidCredentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_accessToken == null) {
|
||||
// Try to load from shared preferences first
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_accessToken = prefs.getString('spotify_access_token');
|
||||
|
||||
if (_accessToken == null) {
|
||||
await _getAccessToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SpotifyTrack>> searchTracks(String query, {int limit = 20}) async {
|
||||
try {
|
||||
await _ensureValidToken();
|
||||
|
||||
// If no valid credentials or token, use demo data
|
||||
if (!_hasValidCredentials || _accessToken == null) {
|
||||
print('Using demo data for search: $query');
|
||||
return _getDemoTracks(query);
|
||||
}
|
||||
|
||||
final encodedQuery = Uri.encodeQueryComponent(query);
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/search?q=$encodedQuery&type=track&limit=$limit'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $_accessToken',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final searchResponse = SpotifySearchResponse.fromJson(data);
|
||||
print('Found ${searchResponse.tracks.items.length} tracks from Spotify API');
|
||||
return searchResponse.tracks.items;
|
||||
} else if (response.statusCode == 401) {
|
||||
// Token expired, get a new one
|
||||
_accessToken = null;
|
||||
await _getAccessToken();
|
||||
return searchTracks(query, limit: limit); // Retry
|
||||
} else {
|
||||
print('Spotify search failed: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
return _getDemoTracks(query);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error searching Spotify: $e');
|
||||
return _getDemoTracks(query);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SpotifyTrack>> getPopularTracks({String genre = 'chill'}) async {
|
||||
try {
|
||||
await _ensureValidToken();
|
||||
|
||||
if (!_hasValidCredentials || _accessToken == null) {
|
||||
print('Using demo popular tracks');
|
||||
return _getDemoPopularTracks();
|
||||
}
|
||||
|
||||
// Search for popular tracks in the genre
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/search?q=genre:$genre&type=track&limit=10'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $_accessToken',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final searchResponse = SpotifySearchResponse.fromJson(data);
|
||||
return searchResponse.tracks.items;
|
||||
} else {
|
||||
return _getDemoPopularTracks();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting popular tracks: $e');
|
||||
return _getDemoPopularTracks();
|
||||
}
|
||||
}
|
||||
|
||||
// Demo data for when Spotify API is not available
|
||||
List<SpotifyTrack> _getDemoTracks(String query) {
|
||||
final demoTracks = [
|
||||
_createDemoTrack('1', 'Tropical House Cruises', 'Kygo', 'Cloud Nine', 'https://i.scdn.co/image/tropical'),
|
||||
_createDemoTrack('2', 'Summer Breeze', 'Seeb', 'Summer Hits', 'https://i.scdn.co/image/summer'),
|
||||
_createDemoTrack('3', 'Relaxing Waves', 'Chillhop Music', 'Chill Collection', 'https://i.scdn.co/image/waves'),
|
||||
_createDemoTrack('4', 'Sunset Vibes', 'Odesza', 'In Return', 'https://i.scdn.co/image/sunset'),
|
||||
_createDemoTrack('5', 'Ocean Dreams', 'Emancipator', 'Soon It Will Be Cold Enough', 'https://i.scdn.co/image/ocean'),
|
||||
];
|
||||
|
||||
// Filter based on query
|
||||
if (query.toLowerCase().contains('tropical') || query.toLowerCase().contains('kygo')) {
|
||||
return [demoTracks[0]];
|
||||
} else if (query.toLowerCase().contains('summer')) {
|
||||
return [demoTracks[1]];
|
||||
} else if (query.toLowerCase().contains('chill') || query.toLowerCase().contains('relax')) {
|
||||
return [demoTracks[2], demoTracks[4]];
|
||||
} else if (query.toLowerCase().contains('sunset')) {
|
||||
return [demoTracks[3]];
|
||||
}
|
||||
|
||||
return demoTracks;
|
||||
}
|
||||
|
||||
List<SpotifyTrack> _getDemoPopularTracks() {
|
||||
return [
|
||||
_createDemoTrack('pop1', 'Ocean Breeze', 'Lofi Dreams', 'Summer Collection', 'https://i.scdn.co/image/ocean'),
|
||||
_createDemoTrack('pop2', 'Sunset Melody', 'Acoustic Soul', 'Peaceful Moments', 'https://i.scdn.co/image/sunset'),
|
||||
_createDemoTrack('pop3', 'Peaceful Waters', 'Nature Sounds', 'Tranquil Vibes', 'https://i.scdn.co/image/water'),
|
||||
_createDemoTrack('pop4', 'Summer Nights', 'Chill Vibes', 'Evening Sessions', 'https://i.scdn.co/image/night'),
|
||||
];
|
||||
}
|
||||
|
||||
SpotifyTrack _createDemoTrack(String id, String name, String artistName, String albumName, String imageUrl) {
|
||||
return SpotifyTrack(
|
||||
id: id,
|
||||
name: name,
|
||||
artists: [SpotifyArtist(id: 'artist_$id', name: artistName)],
|
||||
album: SpotifyAlbum(
|
||||
id: 'album_$id',
|
||||
name: albumName,
|
||||
images: [SpotifyImage(height: 640, width: 640, url: imageUrl)],
|
||||
),
|
||||
durationMs: 210000 + (id.hashCode % 120000), // Random duration between 3:30 and 5:30
|
||||
externalUrls: {'spotify': 'https://open.spotify.com/track/$id'},
|
||||
previewUrl: null,
|
||||
);
|
||||
}
|
||||
|
||||
// Method to check if Spotify API is properly configured
|
||||
static bool get isConfigured =>
|
||||
SpotifyCredentials.clientId != 'YOUR_SPOTIFY_CLIENT_ID' &&
|
||||
SpotifyCredentials.clientSecret != 'YOUR_SPOTIFY_CLIENT_SECRET' &&
|
||||
SpotifyCredentials.clientId.isNotEmpty &&
|
||||
SpotifyCredentials.clientSecret.isNotEmpty;
|
||||
|
||||
// Method to get configuration status for UI display
|
||||
static String get configurationStatus {
|
||||
if (isConfigured) {
|
||||
return 'Spotify API configured ✓';
|
||||
} else {
|
||||
return 'Using demo data (Spotify not configured)';
|
||||
}
|
||||
}
|
||||
}
|
139
CHALLENGE_2/sleepysound/lib/widgets/network_demo_widget.dart
Normal file
|
@ -0,0 +1,139 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../services/network_group_service.dart';
|
||||
|
||||
class NetworkDemoWidget extends StatefulWidget {
|
||||
final NetworkGroupService networkService;
|
||||
|
||||
const NetworkDemoWidget({
|
||||
super.key,
|
||||
required this.networkService,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NetworkDemoWidget> createState() => _NetworkDemoWidgetState();
|
||||
}
|
||||
|
||||
class _NetworkDemoWidgetState extends State<NetworkDemoWidget> {
|
||||
bool _isDemoMode = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D2D2D),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF6366F1), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.developer_mode,
|
||||
color: Color(0xFF6366F1),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Demo Mode',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: _isDemoMode,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isDemoMode = value;
|
||||
});
|
||||
_toggleDemoMode(value);
|
||||
},
|
||||
activeColor: const Color(0xFF6366F1),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isDemoMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Demo mode simulates network connectivity and adds sample users for testing.',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _addDemoUser,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
foregroundColor: const Color(0xFF6366F1),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text('Add Demo User'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _simulateVote,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF22C55E).withOpacity(0.2),
|
||||
foregroundColor: const Color(0xFF22C55E),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text('Simulate Vote'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleDemoMode(bool enabled) {
|
||||
if (enabled) {
|
||||
// Simulate network connection
|
||||
widget.networkService.simulateNetworkConnection();
|
||||
_addInitialDemoUsers();
|
||||
} else {
|
||||
// Clear demo users
|
||||
widget.networkService.clearDemoUsers();
|
||||
}
|
||||
}
|
||||
|
||||
void _addInitialDemoUsers() {
|
||||
final demoUsers = [
|
||||
{'name': 'Alex M.', 'votes': 5},
|
||||
{'name': 'Sarah K.', 'votes': 3},
|
||||
{'name': 'Marco R.', 'votes': 7},
|
||||
{'name': 'Lisa F.', 'votes': 2},
|
||||
];
|
||||
|
||||
for (var user in demoUsers) {
|
||||
widget.networkService.addDemoUser(user['name'] as String, user['votes'] as int);
|
||||
}
|
||||
}
|
||||
|
||||
void _addDemoUser() {
|
||||
final names = ['Tom B.', 'Emma W.', 'David L.', 'Anna K.', 'Mike R.', 'Julia S.'];
|
||||
final randomName = names[DateTime.now().millisecond % names.length];
|
||||
final randomVotes = (DateTime.now().millisecond % 10) + 1;
|
||||
|
||||
widget.networkService.addDemoUser(randomName, randomVotes);
|
||||
}
|
||||
|
||||
void _simulateVote() {
|
||||
widget.networkService.updateUserVotes(widget.networkService.currentUser.votes + 1);
|
||||
}
|
||||
}
|
250
CHALLENGE_2/sleepysound/lib/widgets/network_widgets.dart
Normal file
|
@ -0,0 +1,250 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../services/network_group_service.dart';
|
||||
|
||||
class NetworkStatusCard extends StatelessWidget {
|
||||
final NetworkGroupService networkService;
|
||||
|
||||
const NetworkStatusCard({
|
||||
super.key,
|
||||
required this.networkService,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isConnected = networkService.isConnectedToWifi;
|
||||
final networkName = networkService.currentNetworkName;
|
||||
final localIp = networkService.localIpAddress;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isConnected
|
||||
? const Color(0xFF22C55E).withOpacity(0.1)
|
||||
: const Color(0xFFEF4444).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: isConnected
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFEF4444),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
isConnected ? Icons.wifi : Icons.wifi_off,
|
||||
size: 40,
|
||||
color: isConnected
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFEF4444),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
isConnected
|
||||
? '📶 Connected to $networkName'
|
||||
: '❌ Not connected to WiFi',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isConnected
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFEF4444),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
isConnected
|
||||
? 'You can now vote and suggest music with others on this network!'
|
||||
: 'Please connect to WiFi to join the group session',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isConnected && localIp.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Your IP: $localIp',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserCard extends StatelessWidget {
|
||||
final NetworkUser user;
|
||||
final bool isCurrentUser;
|
||||
|
||||
const UserCard({
|
||||
super.key,
|
||||
required this.user,
|
||||
this.isCurrentUser = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentUser
|
||||
? const Color(0xFF6366F1).withOpacity(0.1)
|
||||
: const Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isCurrentUser
|
||||
? Border.all(color: const Color(0xFF6366F1), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: isCurrentUser
|
||||
? const Color(0xFF6366F1)
|
||||
: const Color(0xFF4B5563),
|
||||
child: Text(
|
||||
user.name.substring(0, 1),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (user.isOnline)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF22C55E),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFF121212), width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
user.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (isCurrentUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'You',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Joined ${_formatDuration(DateTime.now().difference(user.joinedAt))} ago',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (!user.isOnline) ...[
|
||||
const Text(
|
||||
' • ',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
const Text(
|
||||
'Offline',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (user.ipAddress.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'IP: ${user.ipAddress}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${user.votes} votes',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6366F1),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
if (duration.inMinutes < 1) {
|
||||
return 'less than a minute';
|
||||
} else if (duration.inMinutes < 60) {
|
||||
return '${duration.inMinutes} minute${duration.inMinutes == 1 ? '' : 's'}';
|
||||
} else {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
if (minutes == 0) {
|
||||
return '$hours hour${hours == 1 ? '' : 's'}';
|
||||
} else {
|
||||
return '$hours hour${hours == 1 ? '' : 's'} $minutes minute${minutes == 1 ? '' : 's'}';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
125
CHALLENGE_2/sleepysound/lib/widgets/user_activity_status.dart
Normal file
|
@ -0,0 +1,125 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../services/spam_protection_service.dart';
|
||||
|
||||
class UserActivityStatus extends StatelessWidget {
|
||||
final SpamProtectionService spamService;
|
||||
|
||||
const UserActivityStatus({
|
||||
super.key,
|
||||
required this.spamService,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userId = spamService.getCurrentUserId();
|
||||
final stats = spamService.getUserStats(userId);
|
||||
final blockMessage = spamService.getBlockMessage(userId);
|
||||
|
||||
if (blockMessage != null) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.block, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
blockMessage,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Show activity limits if user is getting close
|
||||
final votesUsed = stats['votesThisHour']!;
|
||||
final suggestionsUsed = stats['suggestionsThisHour']!;
|
||||
final maxVotes = stats['maxVotesPerHour']!;
|
||||
final maxSuggestions = stats['maxSuggestionsPerHour']!;
|
||||
|
||||
final showWarning = votesUsed > maxVotes * 0.8 || suggestionsUsed > maxSuggestions * 0.8;
|
||||
|
||||
if (!showWarning) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.orange, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Activity Limits',
|
||||
style: TextStyle(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Votes: $votesUsed/$maxVotes',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
LinearProgressIndicator(
|
||||
value: votesUsed / maxVotes,
|
||||
backgroundColor: Colors.grey.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
votesUsed > maxVotes * 0.9 ? Colors.red : Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Suggestions: $suggestionsUsed/$maxSuggestions',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
LinearProgressIndicator(
|
||||
value: suggestionsUsed / maxSuggestions,
|
||||
backgroundColor: Colors.grey.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
suggestionsUsed > maxSuggestions * 0.9 ? Colors.red : Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -6,6 +6,14 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 581 B |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 312 KiB |
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 312 KiB |
|
@ -32,4 +32,4 @@
|
|||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -6,6 +6,15 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
connectivity_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
BIN
PlantDashboard/.DS_Store
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
BIN
PlantDashboard/public/.DS_Store
vendored
BIN
PlantDashboard/public/logo.jpg
Normal file
After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 126 KiB |
Before Width: | Height: | Size: 133 KiB |
Before Width: | Height: | Size: 1.3 MiB |
BIN
PlantDashboard/public/transparentLogo.png
Normal file
After Width: | Height: | Size: 84 KiB |
|
@ -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
|
||||
|
|
185
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.
|
||||
|
||||
---
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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']}")
|
15
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.
|
||||
|
||||
|
|
60
script.py
|
@ -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)
|