// lib/ui/pinepods/history.dart import 'package:flutter/material.dart'; import 'package:pinepods_mobile/bloc/settings/settings_bloc.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/entities/pinepods_episode.dart'; import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; import 'package:pinepods_mobile/ui/utils/local_download_utils.dart'; import 'package:pinepods_mobile/ui/utils/player_utils.dart'; import 'package:pinepods_mobile/ui/utils/position_utils.dart'; import 'package:pinepods_mobile/ui/widgets/server_error_page.dart'; import 'package:pinepods_mobile/services/error_handling_service.dart'; import 'package:pinepods_mobile/services/global_services.dart'; import 'package:provider/provider.dart'; import 'package:sliver_tools/sliver_tools.dart'; class PinepodsHistory extends StatefulWidget { const PinepodsHistory({Key? key}) : super(key: key); @override State createState() => _PinepodsHistoryState(); } class _PinepodsHistoryState extends State { bool _isLoading = false; String _errorMessage = ''; List _episodes = []; List _filteredEpisodes = []; final PinepodsService _pinepodsService = PinepodsService(); // Use global audio service instead of creating local instance int? _contextMenuEpisodeIndex; final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; @override void initState() { super.initState(); _loadHistory(); _searchController.addListener(_onSearchChanged); } @override void dispose() { _searchController.dispose(); // Don't dispose global audio service - it should persist across pages super.dispose(); } void _onSearchChanged() { setState(() { _searchQuery = _searchController.text; _filterEpisodes(); }); } void _filterEpisodes() { if (_searchQuery.isEmpty) { _filteredEpisodes = List.from(_episodes); } else { _filteredEpisodes = _episodes.where((episode) { return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) || episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase()); }).toList(); } } PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; Future _loadHistory() async { setState(() { _isLoading = true; _errorMessage = ''; }); try { final settingsBloc = Provider.of(context, listen: false); final settings = settingsBloc.currentSettings; if (settings.pinepodsServer == null || settings.pinepodsApiKey == null || settings.pinepodsUserId == null) { setState(() { _errorMessage = 'Not connected to PinePods server. Please login first.'; _isLoading = false; }); return; } _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); final userId = settings.pinepodsUserId!; final episodes = await _pinepodsService.getUserHistory(userId); // Enrich episodes with best available positions (local vs server) final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions( context, _pinepodsService, episodes, userId, ); setState(() { _episodes = enrichedEpisodes; // Sort episodes by publication date (newest first) _episodes.sort((a, b) { try { final dateA = DateTime.parse(a.episodePubDate); final dateB = DateTime.parse(b.episodePubDate); return dateB.compareTo(dateA); // Newest first } catch (e) { return 0; // Keep original order if parsing fails } }); _filterEpisodes(); // Initialize filtered list _isLoading = false; }); // After loading episodes, check their local download status await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes); } catch (e) { setState(() { _errorMessage = 'Failed to load listening history: ${e.toString()}'; _isLoading = false; }); } } Future _refresh() async { // Clear local download status cache on refresh LocalDownloadUtils.clearCache(); await _loadHistory(); } 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 { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( children: [ const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 12), Text('Starting ${episode.episodeTitle}...'), ], ), duration: const Duration(seconds: 2), ), ); await _audioService!.playPinepodsEpisode( pinepodsEpisode: episode, resume: episode.isStarted, ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Now playing: ${episode.episodeTitle}'), backgroundColor: Colors.green, duration: const Duration(seconds: 2), ), ); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to play episode: ${e.toString()}'), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } } Future _showContextMenu(int episodeIndex) async { final episode = _episodes[episodeIndex]; final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode); if (!mounted) return; showDialog( context: context, barrierColor: Colors.black.withOpacity(0.3), builder: (context) => EpisodeContextMenu( episode: episode, isDownloadedLocally: isDownloadedLocally, 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); }, onDeleteLocalDownload: () { Navigator.of(context).pop(); _deleteLocalDownload(episodeIndex); }, onQueue: () { Navigator.of(context).pop(); _toggleQueueEpisode(episodeIndex); }, onMarkComplete: () { Navigator.of(context).pop(); _toggleMarkComplete(episodeIndex); }, onDismiss: () { Navigator.of(context).pop(); }, ), ); } Future _localDownloadEpisode(int episodeIndex) async { final episode = _episodes[episodeIndex]; final success = await LocalDownloadUtils.localDownloadEpisode(context, episode); if (success) { LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green); } else { LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red); } } Future _deleteLocalDownload(int episodeIndex) async { final episode = _episodes[episodeIndex]; final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode); if (deletedCount > 0) { LocalDownloadUtils.showSnackBar( context, 'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}', Colors.orange ); } else { LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red); } } void _hideContextMenu() { setState(() { _contextMenuEpisodeIndex = null; }); } Future _saveEpisode(int episodeIndex) async { final episode = _episodes[episodeIndex]; 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; } _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); try { final success = await _pinepodsService.saveEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success) { setState(() { _episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true); _filterEpisodes(); // Update filtered list to reflect changes }); _showSnackBar('Episode saved!', Colors.green); } else { _showSnackBar('Failed to save episode', Colors.red); } } catch (e) { _showSnackBar('Error saving episode: $e', Colors.red); } _hideContextMenu(); } Future _removeSavedEpisode(int episodeIndex) async { final episode = _episodes[episodeIndex]; 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; } _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); try { final success = await _pinepodsService.removeSavedEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success) { setState(() { _episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false); _filterEpisodes(); // Update filtered list to reflect changes }); _showSnackBar('Removed from saved episodes', Colors.orange); } else { _showSnackBar('Failed to remove saved episode', Colors.red); } } catch (e) { _showSnackBar('Error removing saved episode: $e', Colors.red); } _hideContextMenu(); } Future _downloadEpisode(int episodeIndex) async { final episode = _episodes[episodeIndex]; 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; } _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); try { final success = await _pinepodsService.downloadEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success) { setState(() { _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true); _filterEpisodes(); // Update filtered list to reflect changes }); _showSnackBar('Episode download queued!', Colors.green); } else { _showSnackBar('Failed to queue download', Colors.red); } } catch (e) { _showSnackBar('Error downloading episode: $e', Colors.red); } _hideContextMenu(); } Future _deleteEpisode(int episodeIndex) async { final episode = _episodes[episodeIndex]; 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; } _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); try { final success = await _pinepodsService.deleteEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success) { setState(() { _episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false); _filterEpisodes(); // Update filtered list to reflect changes }); _showSnackBar('Episode deleted from server', Colors.orange); } else { _showSnackBar('Failed to delete episode', Colors.red); } } catch (e) { _showSnackBar('Error deleting episode: $e', Colors.red); } _hideContextMenu(); } Future _toggleQueueEpisode(int episodeIndex) async { final episode = _episodes[episodeIndex]; 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; } _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); try { bool success; if (episode.queued) { success = await _pinepodsService.removeQueuedEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success) { setState(() { _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false); _filterEpisodes(); // Update filtered list to reflect changes }); _showSnackBar('Removed from queue', Colors.orange); } } else { success = await _pinepodsService.queueEpisode( episode.episodeId, userId, episode.isYoutube, ); if (success) { setState(() { _episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true); _filterEpisodes(); // Update filtered list to reflect changes }); _showSnackBar('Added to queue!', Colors.green); } } if (!success) { _showSnackBar('Failed to update queue', Colors.red); } } catch (e) { _showSnackBar('Error updating queue: $e', Colors.red); } _hideContextMenu(); } Future _toggleMarkComplete(int episodeIndex) async { final episode = _episodes[episodeIndex]; 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; } _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); try { bool success; if (episode.completed) { success = await _pinepodsService.markEpisodeUncompleted( episode.episodeId, userId, episode.isYoutube, ); if (success) { setState(() { _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false); _filterEpisodes(); // Update filtered list to reflect changes }); _showSnackBar('Marked as incomplete', Colors.orange); } } else { success = await _pinepodsService.markEpisodeCompleted( episode.episodeId, userId, episode.isYoutube, ); if (success) { setState(() { _episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true); _filterEpisodes(); // Update filtered list to reflect changes }); _showSnackBar('Marked as complete!', Colors.green); } } if (!success) { _showSnackBar('Failed to update completion status', Colors.red); } } catch (e) { _showSnackBar('Error updating completion: $e', Colors.red); } _hideContextMenu(); } PinepodsEpisode _updateEpisodeProperty( PinepodsEpisode episode, { bool? saved, bool? downloaded, bool? queued, bool? completed, }) { return PinepodsEpisode( podcastName: episode.podcastName, episodeTitle: episode.episodeTitle, episodePubDate: episode.episodePubDate, episodeDescription: episode.episodeDescription, episodeArtwork: episode.episodeArtwork, episodeUrl: episode.episodeUrl, episodeDuration: episode.episodeDuration, listenDuration: episode.listenDuration, episodeId: episode.episodeId, completed: completed ?? episode.completed, saved: saved ?? episode.saved, queued: queued ?? episode.queued, downloaded: downloaded ?? episode.downloaded, isYoutube: episode.isYoutube, ); } void _showSnackBar(String message, Color backgroundColor) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: backgroundColor, duration: const Duration(seconds: 2), ), ); } @override Widget build(BuildContext context) { if (_isLoading) { return const SliverFillRemaining( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Loading listening history...'), ], ), ), ); } if (_errorMessage.isNotEmpty) { return SliverServerErrorPage( errorMessage: _errorMessage.isServerConnectionError ? null : _errorMessage, onRetry: _refresh, title: 'History Unavailable', subtitle: _errorMessage.isServerConnectionError ? 'Unable to connect to the PinePods server' : 'Failed to load listening history', ); } if (_episodes.isEmpty) { return const SliverFillRemaining( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.history, size: 64, color: Colors.grey, ), SizedBox(height: 16), Text( 'No listening history', style: TextStyle( fontSize: 18, color: Colors.grey, ), ), SizedBox(height: 8), Text( 'Episodes you listen to will appear here', style: TextStyle( color: Colors.grey, ), textAlign: TextAlign.center, ), ], ), ), ); } return MultiSliver( children: [ _buildSearchBar(), _buildEpisodesList(), ], ); } Widget _buildSearchBar() { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16.0), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Filter episodes...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); }, ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), filled: true, fillColor: Theme.of(context).cardColor, ), ), ), ); } Widget _buildEpisodesList() { // Check if search returned no results if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) { return SliverFillRemaining( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.search_off, size: 64, color: Theme.of(context).primaryColor, ), const SizedBox(height: 16), Text( 'No episodes found', style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( 'No episodes match "$_searchQuery"', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), ], ), ), ); } return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (index == 0) { // Header return Padding( padding: const EdgeInsets.all(16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _searchQuery.isEmpty ? 'Listening History' : 'Search Results (${_filteredEpisodes.length})', style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, ), ), IconButton( icon: const Icon(Icons.refresh), onPressed: _refresh, ), ], ), ); } // Episodes (index - 1 because of header) final episodeIndex = index - 1; final episode = _filteredEpisodes[episodeIndex]; // Find the original index for context menu operations final originalIndex = _episodes.indexOf(episode); return PinepodsEpisodeCard( episode: episode, onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PinepodsEpisodeDetails( initialEpisode: episode, ), ), ); }, onLongPress: originalIndex >= 0 ? () => _showContextMenu(originalIndex) : null, onPlayPressed: () => _playEpisode(episode), ); }, childCount: _filteredEpisodes.length + 1, // +1 for header ), ); } }