Compare commits
11 commits
main
...
challenge-
Author | SHA1 | Date | |
---|---|---|---|
1ba28e04c2 | |||
3efab4f59b | |||
1ca5a639d5 | |||
8bc45ad6fd | |||
a91654df03 | |||
6b93f1206d | |||
025eee7644 | |||
f70fe3cdd1 | |||
1ce7aea6b5 | |||
5c84b2fb59 | |||
0749836d17 |
5
CHALLENGE_2/sleepysound/.gitignore
vendored
|
@ -1,3 +1,8 @@
|
|||
# API Secret Spotify
|
||||
SPOTIFY_SECRET.dart
|
||||
|
||||
|
||||
|
||||
# Miscellaneous
|
||||
*.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
|
||||
|
|