import 'dart:async'; import 'package:flutter/material.dart'; import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; import 'package:pinepods_mobile/entities/pinepods_episode.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; import 'package:pinepods_mobile/services/global_services.dart'; import 'package:pinepods_mobile/services/search_history_service.dart'; import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart'; import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; import 'package:provider/provider.dart'; /// Episode search page for finding episodes in user's subscriptions /// /// This page allows users to search through episodes in their subscribed podcasts /// with debounced search input and animated loading states. class EpisodeSearchPage extends StatefulWidget { const EpisodeSearchPage({Key? key}) : super(key: key); @override State createState() => _EpisodeSearchPageState(); } class _EpisodeSearchPageState extends State with TickerProviderStateMixin { final PinepodsService _pinepodsService = PinepodsService(); final SearchHistoryService _searchHistoryService = SearchHistoryService(); final TextEditingController _searchController = TextEditingController(); final FocusNode _focusNode = FocusNode(); Timer? _debounceTimer; List _searchResults = []; List _searchHistory = []; bool _isLoading = false; bool _hasSearched = false; bool _showHistory = false; String? _errorMessage; String _currentQuery = ''; // Use global audio service instead of creating local instance int? _contextMenuEpisodeIndex; // Animation controllers late AnimationController _fadeAnimationController; late AnimationController _slideAnimationController; late Animation _fadeAnimation; late Animation _slideAnimation; @override void initState() { super.initState(); _setupAnimations(); _setupSearch(); } void _setupAnimations() { // Fade animation for results _fadeAnimationController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); _fadeAnimation = Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: _fadeAnimationController, curve: Curves.easeInOut, )); // Slide animation for search bar _slideAnimationController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _slideAnimation = Tween( begin: const Offset(0, 0), end: const Offset(0, -0.2), ).animate(CurvedAnimation( parent: _slideAnimationController, curve: Curves.easeInOut, )); } void _setupSearch() { final settingsBloc = Provider.of(context, listen: false); final settings = settingsBloc.currentSettings; if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) { _pinepodsService.setCredentials( settings.pinepodsServer!, settings.pinepodsApiKey!, ); GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); } _searchController.addListener(_onSearchChanged); _loadSearchHistory(); } Future _loadSearchHistory() async { final history = await _searchHistoryService.getEpisodeSearchHistory(); if (mounted) { setState(() { _searchHistory = history; }); } } void _selectHistoryItem(String searchTerm) { _searchController.text = searchTerm; _performSearch(searchTerm); } Future _removeHistoryItem(String searchTerm) async { await _searchHistoryService.removeEpisodeSearchTerm(searchTerm); await _loadSearchHistory(); } PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; Future _playEpisode(PinepodsEpisode episode) async { if (_audioService == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Audio service not available'), backgroundColor: Colors.red, ), ); return; } try { await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Playing ${episode.episodeTitle}'), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to play episode: $e'), backgroundColor: Colors.red, ), ); } } } void _showContextMenu(int episodeIndex) { setState(() { _contextMenuEpisodeIndex = episodeIndex; }); } void _hideContextMenu() { setState(() { _contextMenuEpisodeIndex = null; }); } Future _saveEpisode(int episodeIndex) async { final episode = _searchResults[episodeIndex].toPinepodsEpisode(); final settingsBloc = Provider.of(context, listen: false); final settings = settingsBloc.currentSettings; final userId = settings.pinepodsUserId; if (userId == null) { _showSnackBar('Not logged in', Colors.red); return; } try { final success = await _pinepodsService.saveEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success && mounted) { _showSnackBar('Episode saved', Colors.green); // Update local state setState(() { _searchResults[episodeIndex] = SearchEpisodeResult( podcastId: _searchResults[episodeIndex].podcastId, podcastName: _searchResults[episodeIndex].podcastName, artworkUrl: _searchResults[episodeIndex].artworkUrl, author: _searchResults[episodeIndex].author, categories: _searchResults[episodeIndex].categories, description: _searchResults[episodeIndex].description, episodeCount: _searchResults[episodeIndex].episodeCount, feedUrl: _searchResults[episodeIndex].feedUrl, websiteUrl: _searchResults[episodeIndex].websiteUrl, explicit: _searchResults[episodeIndex].explicit, userId: _searchResults[episodeIndex].userId, episodeId: _searchResults[episodeIndex].episodeId, episodeTitle: _searchResults[episodeIndex].episodeTitle, episodeDescription: _searchResults[episodeIndex].episodeDescription, episodePubDate: _searchResults[episodeIndex].episodePubDate, episodeArtwork: _searchResults[episodeIndex].episodeArtwork, episodeUrl: _searchResults[episodeIndex].episodeUrl, episodeDuration: _searchResults[episodeIndex].episodeDuration, completed: _searchResults[episodeIndex].completed, saved: true, // We just saved it queued: _searchResults[episodeIndex].queued, downloaded: _searchResults[episodeIndex].downloaded, isYoutube: _searchResults[episodeIndex].isYoutube, listenDuration: _searchResults[episodeIndex].listenDuration, ); }); } else if (mounted) { _showSnackBar('Failed to save episode', Colors.red); } } catch (e) { if (mounted) { _showSnackBar('Error saving episode: $e', Colors.red); } } } Future _removeSavedEpisode(int episodeIndex) async { final episode = _searchResults[episodeIndex].toPinepodsEpisode(); final settingsBloc = Provider.of(context, listen: false); final settings = settingsBloc.currentSettings; final userId = settings.pinepodsUserId; if (userId == null) { _showSnackBar('Not logged in', Colors.red); return; } try { final success = await _pinepodsService.removeSavedEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success && mounted) { _showSnackBar('Episode removed from saved', Colors.orange); } else if (mounted) { _showSnackBar('Failed to remove saved episode', Colors.red); } } catch (e) { if (mounted) { _showSnackBar('Error removing saved episode: $e', Colors.red); } } } Future _downloadEpisode(int episodeIndex) async { final episode = _searchResults[episodeIndex].toPinepodsEpisode(); _showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue); // Note: Actual download implementation would depend on download service integration } Future _deleteEpisode(int episodeIndex) async { final episode = _searchResults[episodeIndex].toPinepodsEpisode(); _showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange); // Note: Actual delete implementation would depend on download service integration } Future _localDownloadEpisode(int episodeIndex) async { final episode = _searchResults[episodeIndex].toPinepodsEpisode(); _showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue); // Note: Actual local download implementation would depend on download service integration } Future _toggleQueueEpisode(int episodeIndex) async { final episode = _searchResults[episodeIndex].toPinepodsEpisode(); final settingsBloc = Provider.of(context, listen: false); final settings = settingsBloc.currentSettings; final userId = settings.pinepodsUserId; if (userId == null) { _showSnackBar('Not logged in', Colors.red); return; } try { if (episode.queued) { final success = await _pinepodsService.removeQueuedEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success && mounted) { _showSnackBar('Episode removed from queue', Colors.orange); } } else { final success = await _pinepodsService.queueEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success && mounted) { _showSnackBar('Episode added to queue', Colors.green); } } } catch (e) { if (mounted) { _showSnackBar('Error updating queue: $e', Colors.red); } } } Future _toggleMarkComplete(int episodeIndex) async { final episode = _searchResults[episodeIndex].toPinepodsEpisode(); final settingsBloc = Provider.of(context, listen: false); final settings = settingsBloc.currentSettings; final userId = settings.pinepodsUserId; if (userId == null) { _showSnackBar('Not logged in', Colors.red); return; } try { if (episode.completed) { final success = await _pinepodsService.markEpisodeUncompleted( episode.episodeId, userId, episode.isYoutube, ); if (success && mounted) { _showSnackBar('Episode marked as incomplete', Colors.orange); } } else { final success = await _pinepodsService.markEpisodeCompleted( episode.episodeId, userId, episode.isYoutube, ); if (success && mounted) { _showSnackBar('Episode marked as complete', Colors.green); } } } catch (e) { if (mounted) { _showSnackBar('Error updating completion status: $e', Colors.red); } } } void _showSnackBar(String message, Color backgroundColor) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: backgroundColor, duration: const Duration(seconds: 2), ), ); } } void _onSearchChanged() { final query = _searchController.text.trim(); setState(() { _showHistory = query.isEmpty && _searchHistory.isNotEmpty; }); if (_debounceTimer?.isActive ?? false) { _debounceTimer!.cancel(); } _debounceTimer = Timer(const Duration(milliseconds: 500), () { if (query.isNotEmpty && query != _currentQuery) { _currentQuery = query; _performSearch(query); } else if (query.isEmpty) { _clearResults(); } }); } Future _performSearch(String query) async { setState(() { _isLoading = true; _errorMessage = null; _showHistory = false; }); // Save search term to history await _searchHistoryService.addEpisodeSearchTerm(query); await _loadSearchHistory(); // Animate search bar to top _slideAnimationController.forward(); try { final settingsBloc = Provider.of(context, listen: false); final settings = settingsBloc.currentSettings; final userId = settings.pinepodsUserId; if (userId == null) { throw Exception('Not logged in'); } final results = await _pinepodsService.searchEpisodes(userId, query); setState(() { _searchResults = results; _isLoading = false; _hasSearched = true; }); // Animate results in _fadeAnimationController.forward(); } catch (e) { setState(() { _errorMessage = e.toString(); _isLoading = false; _hasSearched = true; _searchResults = []; }); } } void _clearResults() { setState(() { _searchResults = []; _hasSearched = false; _errorMessage = null; _currentQuery = ''; _showHistory = _searchHistory.isNotEmpty; }); _fadeAnimationController.reset(); _slideAnimationController.reverse(); } Widget _buildSearchBar() { return SlideTransition( position: _slideAnimation, child: Container( padding: const EdgeInsets.all(16), child: Card( elevation: 4, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(30), ), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(30), gradient: LinearGradient( colors: [ Theme.of(context).primaryColor.withOpacity(0.1), Theme.of(context).primaryColor.withOpacity(0.05), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: TextField( controller: _searchController, focusNode: _focusNode, style: Theme.of(context).textTheme.bodyLarge, onTap: () { setState(() { _showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty; }); }, decoration: InputDecoration( hintText: 'Search for episodes...', hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, ), prefixIcon: Icon( Icons.search, color: Theme.of(context).primaryColor, ), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: Icon( Icons.clear, color: Theme.of(context).primaryColor, ), onPressed: () { _searchController.clear(); _clearResults(); _focusNode.requestFocus(); }, ) : null, border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( horizontal: 20, vertical: 16, ), ), ), ), ), ), ); } Widget _buildLoadingIndicator() { return Container( padding: const EdgeInsets.all(64), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text( 'Searching...', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context).primaryColor, ), ), ], ), ); } Widget _buildEmptyState() { if (!_hasSearched) { return Container( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.search, size: 64, color: Theme.of(context).primaryColor.withOpacity(0.5), ), const SizedBox(height: 16), Text( 'Search Your Episodes', style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), Text( 'Find episodes from your subscribed podcasts', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, ), textAlign: TextAlign.center, ), ], ), ); } return Container( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.search_off, size: 64, color: Theme.of(context).hintColor, ), const SizedBox(height: 16), Text( 'No Episodes Found', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 8), Text( 'Try adjusting your search terms', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, ), ), ], ), ); } Widget _buildErrorState() { return Container( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error, ), const SizedBox(height: 16), Text( 'Search Error', style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: Theme.of(context).colorScheme.error, ), ), const SizedBox(height: 8), Text( _errorMessage ?? 'Unknown error occurred', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), const SizedBox(height: 16), ElevatedButton( onPressed: () { if (_currentQuery.isNotEmpty) { _performSearch(_currentQuery); } }, child: const Text('Try Again'), ), ], ), ); } Widget _buildResults() { // Convert search results to PinepodsEpisode objects final episodes = _searchResults.map((result) => result.toPinepodsEpisode()).toList(); return FadeTransition( opacity: _fadeAnimation, child: PaginatedEpisodeList( episodes: episodes, isServerEpisodes: true, pageSize: 20, // Show 20 episodes at a time for good performance onEpisodeTap: (episode) { Navigator.push( context, MaterialPageRoute( builder: (context) => PinepodsEpisodeDetails( initialEpisode: episode, ), ), ); }, onEpisodeLongPress: (episode, globalIndex) { // Find the original index in _searchResults for context menu final originalIndex = _searchResults.indexWhere( (result) => result.episodeId == episode.episodeId ); if (originalIndex != -1) { _showContextMenu(originalIndex); } }, onPlayPressed: (episode) => _playEpisode(episode), ), ); } Widget _buildSearchHistory() { return Container( margin: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Recent Searches', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold, ), ), const Spacer(), if (_searchHistory.isNotEmpty) TextButton( onPressed: () async { await _searchHistoryService.clearEpisodeSearchHistory(); await _loadSearchHistory(); }, child: Text( 'Clear All', style: TextStyle( color: Theme.of(context).hintColor, fontSize: 12, ), ), ), ], ), const SizedBox(height: 8), ..._searchHistory.take(10).map((searchTerm) => Card( margin: const EdgeInsets.symmetric(vertical: 2), child: ListTile( dense: true, leading: Icon( Icons.history, color: Theme.of(context).hintColor, size: 20, ), title: Text( searchTerm, style: Theme.of(context).textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: IconButton( icon: Icon( Icons.close, size: 18, color: Theme.of(context).hintColor, ), onPressed: () => _removeHistoryItem(searchTerm), ), onTap: () => _selectHistoryItem(searchTerm), ), )).toList(), ], ), ); } @override Widget build(BuildContext context) { // Show context menu as a modal overlay if needed if (_contextMenuEpisodeIndex != null) { final episodeIndex = _contextMenuEpisodeIndex!; // Store locally to avoid null issues final episode = _searchResults[episodeIndex].toPinepodsEpisode(); WidgetsBinding.instance.addPostFrameCallback((_) { showDialog( context: context, barrierColor: Colors.black.withOpacity(0.3), builder: (context) => EpisodeContextMenu( episode: episode, onSave: () { Navigator.of(context).pop(); _saveEpisode(episodeIndex); }, onRemoveSaved: () { Navigator.of(context).pop(); _removeSavedEpisode(episodeIndex); }, onDownload: episode.downloaded ? () { Navigator.of(context).pop(); _deleteEpisode(episodeIndex); } : () { Navigator.of(context).pop(); _downloadEpisode(episodeIndex); }, onLocalDownload: () { Navigator.of(context).pop(); _localDownloadEpisode(episodeIndex); }, onQueue: () { Navigator.of(context).pop(); _toggleQueueEpisode(episodeIndex); }, onMarkComplete: () { Navigator.of(context).pop(); _toggleMarkComplete(episodeIndex); }, onDismiss: () { Navigator.of(context).pop(); _hideContextMenu(); }, ), ); }); // Reset the context menu index after storing it locally _contextMenuEpisodeIndex = null; } return SliverFillRemaining( child: GestureDetector( onTap: () { // Dismiss keyboard when tapping outside FocusScope.of(context).unfocus(); }, child: Column( children: [ _buildSearchBar(), Expanded( child: SingleChildScrollView( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: _showHistory ? _buildSearchHistory() : _isLoading ? _buildLoadingIndicator() : _errorMessage != null ? _buildErrorState() : _searchResults.isEmpty ? _buildEmptyState() : _buildResults(), ), ), ), ], ), ), ); } @override void dispose() { _debounceTimer?.cancel(); _searchController.dispose(); _focusNode.dispose(); _fadeAnimationController.dispose(); _slideAnimationController.dispose(); super.dispose(); } }