import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/music_queue_service.dart'; import '../services/spam_protection_service.dart'; import '../services/genre_filter_service.dart'; import '../models/spotify_track.dart'; import '../widgets/user_activity_status.dart'; class VotingPage extends StatefulWidget { const VotingPage({super.key}); @override State createState() => _VotingPageState(); } class _VotingPageState extends State { final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); List _searchResults = []; bool _isLoading = false; String _statusMessage = ''; final LayerLink _layerLink = LayerLink(); OverlayEntry? _overlayEntry; @override void initState() { super.initState(); _loadInitialQueue(); _searchFocusNode.addListener(_onSearchFocusChange); } @override void dispose() { _hideSearchOverlay(); _searchController.dispose(); _searchFocusNode.dispose(); super.dispose(); } void _onSearchFocusChange() { if (_searchFocusNode.hasFocus && _searchResults.isNotEmpty) { _showSearchOverlay(); } else if (!_searchFocusNode.hasFocus) { // Delay hiding to allow for taps on results Future.delayed(const Duration(milliseconds: 150), () { _hideSearchOverlay(); }); } } void _showSearchOverlay() { if (_overlayEntry != null) return; _overlayEntry = _createOverlayEntry(); Overlay.of(context).insert(_overlayEntry!); } void _hideSearchOverlay() { _overlayEntry?.remove(); _overlayEntry = null; } OverlayEntry _createOverlayEntry() { RenderBox renderBox = context.findRenderObject() as RenderBox; var size = renderBox.size; var offset = renderBox.localToGlobal(Offset.zero); return OverlayEntry( builder: (context) => Positioned( left: offset.dx + 20, top: offset.dy + 200, // Adjust based on search field position width: size.width - 40, child: CompositedTransformFollower( link: _layerLink, showWhenUnlinked: false, child: Material( elevation: 8, borderRadius: BorderRadius.circular(12), color: const Color(0xFF1E1E1E), child: Container( constraints: const BoxConstraints(maxHeight: 300), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFF6366F1).withOpacity(0.3)), ), child: _buildSearchResultsOverlay(), ), ), ), ), ); } Widget _buildSearchResultsOverlay() { if (_searchResults.isEmpty) { return Container( padding: const EdgeInsets.all(20), child: const Text( 'No results found', style: TextStyle(color: Colors.grey), textAlign: TextAlign.center, ), ); } return ListView.builder( shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 8), itemCount: _searchResults.length, itemBuilder: (context, index) { final track = _searchResults[index]; return _buildSearchResultItem(track, index); }, ); } Widget _buildSearchResultItem(SpotifyTrack track, int index) { return InkWell( onTap: () { _addToQueue(track); _hideSearchOverlay(); _searchController.clear(); _searchFocusNode.unfocus(); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ // Album Art Container( width: 40, height: 40, decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), color: const Color(0xFF2A2A2A), ), child: track.album.images.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network( track.album.images.first.url, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return const Icon( Icons.music_note, color: Colors.grey, size: 16, ); }, ), ) : const Icon( Icons.music_note, color: Colors.grey, size: 16, ), ), const SizedBox(width: 12), // Track Info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( track.name, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w500, fontSize: 14, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( track.artists.map((a) => a.name).join(', '), style: const TextStyle( color: Colors.grey, fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), // Add Icon const Icon( Icons.add_circle_outline, color: Color(0xFF6366F1), size: 20, ), ], ), ), ); } Future _loadInitialQueue() async { final queueService = Provider.of(context, listen: false); await queueService.initializeQueue(); } Future _searchSpotify(String query) async { if (query.isEmpty) { setState(() { _searchResults = []; _statusMessage = ''; }); _hideSearchOverlay(); return; } // Check if search query is appropriate if (!GenreFilterService.isSearchQueryAppropriate(query)) { final suggestions = GenreFilterService.getAlternativeSearchSuggestions(query); setState(() { _isLoading = false; _statusMessage = 'Search term not suitable for the peaceful Lido atmosphere. Try: ${suggestions.join(', ')}'; _searchResults = []; }); _hideSearchOverlay(); return; } final spamService = Provider.of(context, listen: false); final userId = spamService.getCurrentUserId(); // Check spam protection for suggestions if (!spamService.canSuggest(userId)) { final cooldown = spamService.getSuggestionCooldownRemaining(userId); final blockMessage = spamService.getBlockMessage(userId); setState(() { _isLoading = false; _statusMessage = blockMessage ?? 'Please wait $cooldown seconds before searching again.'; _searchResults = []; }); _hideSearchOverlay(); return; } setState(() { _isLoading = true; _statusMessage = 'Searching for "$query"...'; }); try { final queueService = Provider.of(context, listen: false); final results = await queueService.searchTracks(query); // No filtering on search results - let users see all tracks // Filtering only happens when adding to queue to maintain atmosphere // Record the suggestion attempt spamService.recordSuggestion(userId); setState(() { _searchResults = results; _isLoading = false; if (results.isEmpty) { _statusMessage = 'No tracks found for "$query"'; } else { _statusMessage = 'Found ${results.length} tracks'; } }); // Show overlay if we have results and search field is focused if (results.isNotEmpty && _searchFocusNode.hasFocus) { _showSearchOverlay(); } else { _hideSearchOverlay(); } } catch (e) { setState(() { _isLoading = false; _statusMessage = 'Search failed: ${e.toString()}'; _searchResults = []; }); _hideSearchOverlay(); } } void _addToQueue(SpotifyTrack track) { final spamService = Provider.of(context, listen: false); final userId = spamService.getCurrentUserId(); // Check if user can suggest (add to queue) if (!spamService.canSuggest(userId)) { final cooldown = spamService.getSuggestionCooldownRemaining(userId); final blockMessage = spamService.getBlockMessage(userId); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(blockMessage ?? 'Please wait $cooldown seconds before adding another song.'), duration: const Duration(seconds: 3), backgroundColor: Colors.orange, ), ); return; } // Check if track is appropriate for atmosphere final rejectionReason = GenreFilterService.getRejectionReason(track); if (rejectionReason != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(rejectionReason), duration: const Duration(seconds: 4), backgroundColor: Colors.orange, ), ); return; } final queueService = Provider.of(context, listen: false); queueService.addToQueue(track); // Record the suggestion spamService.recordSuggestion(userId); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Added "${track.name}" to queue'), duration: const Duration(seconds: 2), backgroundColor: const Color(0xFF6366F1), ), ); } @override Widget build(BuildContext context) { return Consumer( builder: (context, queueService, child) { return Scaffold( backgroundColor: const Color(0xFF121212), body: SafeArea( child: Column( children: [ // User Activity Status Consumer( builder: (context, spamService, child) { return UserActivityStatus(spamService: spamService); }, ), // Header with Search Container( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Voting', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white, ), ), // Status indicator Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: const Color(0xFF6366F1).withOpacity(0.2), borderRadius: BorderRadius.circular(20), border: Border.all( color: const Color(0xFF6366F1), width: 1, ), ), child: const Text( '🎵 Spotify', style: TextStyle( color: Color(0xFF6366F1), fontSize: 12, fontWeight: FontWeight.bold, ), ), ), ], ), const SizedBox(height: 20), // Search Bar CompositedTransformTarget( link: _layerLink, child: TextField( controller: _searchController, focusNode: _searchFocusNode, style: const TextStyle(color: Colors.white), decoration: InputDecoration( hintText: 'Search for songs, artists, albums...', hintStyle: const TextStyle(color: Colors.grey), prefixIcon: const Icon(Icons.search, color: Colors.grey), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ if (_isLoading) const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), ), ), ), if (_searchController.text.isNotEmpty) IconButton( icon: const Icon(Icons.clear, color: Colors.grey), onPressed: () { _searchController.clear(); _hideSearchOverlay(); setState(() { _searchResults = []; _statusMessage = ''; }); }, ), ], ), filled: true, fillColor: const Color(0xFF1E1E1E), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF6366F1), width: 2), ), ), onChanged: (value) { // Search as user types (with debounce) if (value.length >= 3) { Future.delayed(const Duration(milliseconds: 500), () { if (_searchController.text == value) { _searchSpotify(value); } }); } else if (value.isEmpty) { setState(() { _searchResults = []; _statusMessage = ''; }); _hideSearchOverlay(); } }, onSubmitted: _searchSpotify, ), ), // Atmosphere Info Container( margin: const EdgeInsets.only(top: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFF6366F1).withOpacity(0.1), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFF6366F1).withOpacity(0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ Icon(Icons.spa, color: Color(0xFF6366F1), size: 20), SizedBox(width: 8), Text( 'Lido Atmosphere', style: TextStyle( color: Color(0xFF6366F1), fontWeight: FontWeight.bold, fontSize: 14, ), ), ], ), const SizedBox(height: 8), Text( GenreFilterService.getAtmosphereDescription(), style: const TextStyle( color: Colors.grey, fontSize: 12, ), ), const SizedBox(height: 12), const Text( 'Try these searches:', style: TextStyle( color: Colors.white, fontWeight: FontWeight.w500, fontSize: 12, ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 4, children: GenreFilterService.getSuggestedSearchTerms() .take(8) .map((term) => InkWell( onTap: () { _searchController.text = term; _searchSpotify(term); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: const Color(0xFF6366F1).withOpacity(0.2), borderRadius: BorderRadius.circular(12), ), child: Text( term, style: const TextStyle( color: Color(0xFF6366F1), fontSize: 11, ), ), ), )) .toList(), ), ], ), ), // Status Message if (_statusMessage.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: Text( _statusMessage, style: const TextStyle( color: Colors.grey, fontSize: 14, ), ), ), ], ), ), // Queue Section Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Music Queue', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white, ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: const Color(0xFF6366F1).withOpacity(0.2), borderRadius: BorderRadius.circular(20), border: Border.all( color: const Color(0xFF6366F1), width: 1, ), ), child: Text( '${queueService.queue.length} songs', style: const TextStyle( color: Color(0xFF6366F1), fontSize: 12, fontWeight: FontWeight.bold, ), ), ), ], ), ), const SizedBox(height: 16), Expanded( child: _buildQueueView(queueService), ), ], ), ), ], ), ), ); }, ); } Widget _buildQueueView(MusicQueueService queueService) { final queue = queueService.queue; if (queue.isEmpty) { return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.queue_music, size: 80, color: Colors.grey, ), SizedBox(height: 20), Text( 'Queue is empty', style: TextStyle( color: Colors.grey, fontSize: 18, fontWeight: FontWeight.bold, ), ), SizedBox(height: 10), Text( 'Search and add songs to get started!', style: TextStyle( color: Colors.grey, fontSize: 16, ), ), ], ), ); } return ListView.builder( padding: const EdgeInsets.all(20), itemCount: queue.length, itemBuilder: (context, index) { final queueItem = queue[index]; return _buildQueueItemCard(queueItem, index, queueService); }, ); } Widget _buildQueueItemCard(QueueItem queueItem, int index, MusicQueueService queueService) { return Card( color: const Color(0xFF1E1E1E), margin: const EdgeInsets.only(bottom: 12), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ // Position CircleAvatar( backgroundColor: const Color(0xFF6366F1), radius: 16, child: Text( '${index + 1}', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), const SizedBox(width: 12), // Album Art Container( width: 50, height: 50, decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), color: const Color(0xFF2A2A2A), ), child: queueItem.track.album.images.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network( queueItem.track.album.images.first.url, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return const Icon( Icons.music_note, color: Colors.grey, size: 20, ); }, ), ) : const Icon( Icons.music_note, color: Colors.grey, size: 20, ), ), const SizedBox(width: 12), // Track Info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( queueItem.track.name, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( queueItem.track.artists.map((a) => a.name).join(', '), style: const TextStyle( color: Colors.grey, fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), // Voting Buttons Column( children: [ Consumer( builder: (context, spamService, child) { final userId = spamService.getCurrentUserId(); final canVote = spamService.canVote(userId); final cooldown = spamService.getVoteCooldownRemaining(userId); return IconButton( onPressed: canVote ? () { queueService.upvote(index); spamService.recordVote(userId); } : null, icon: Icon( Icons.keyboard_arrow_up, color: canVote ? Colors.green : Colors.grey, size: 28, ), tooltip: canVote ? 'Upvote' : 'Wait $cooldown seconds', ); } ), Text( '${queueItem.votes}', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ), Consumer( builder: (context, spamService, child) { final userId = spamService.getCurrentUserId(); final canVote = spamService.canVote(userId); final cooldown = spamService.getVoteCooldownRemaining(userId); return IconButton( onPressed: canVote ? () { queueService.downvote(index); spamService.recordVote(userId); } : null, icon: Icon( Icons.keyboard_arrow_down, color: canVote ? Colors.red : Colors.grey, size: 28, ), tooltip: canVote ? 'Downvote' : 'Wait $cooldown seconds', ); } ), ], ), ], ), ), ); } }