Files
PinePods-nix/PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart
2026-03-03 10:57:43 -05:00

968 lines
33 KiB
Dart

// lib/ui/pinepods/downloads.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/download/download_service.dart';
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.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/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
import 'package:logging/logging.dart';
import 'package:sliver_tools/sliver_tools.dart';
class PinepodsDownloads extends StatefulWidget {
const PinepodsDownloads({super.key});
@override
State<PinepodsDownloads> createState() => _PinepodsDownloadsState();
}
class _PinepodsDownloadsState extends State<PinepodsDownloads> {
final log = Logger('PinepodsDownloads');
final PinepodsService _pinepodsService = PinepodsService();
List<PinepodsEpisode> _serverDownloads = [];
List<Episode> _localDownloads = [];
Map<String, List<PinepodsEpisode>> _serverDownloadsByPodcast = {};
Map<String, List<Episode>> _localDownloadsByPodcast = {};
bool _isLoadingServerDownloads = false;
bool _isLoadingLocalDownloads = false;
String? _errorMessage;
Set<String> _expandedPodcasts = {};
int? _contextMenuEpisodeIndex;
bool _isServerEpisode = false;
// Search functionality
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
Map<String, List<PinepodsEpisode>> _filteredServerDownloadsByPodcast = {};
Map<String, List<Episode>> _filteredLocalDownloadsByPodcast = {};
@override
void initState() {
super.initState();
_loadDownloads();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterDownloads();
});
}
void _filterDownloads() {
// Filter server downloads
_filteredServerDownloadsByPodcast = {};
for (final entry in _serverDownloadsByPodcast.entries) {
final podcastName = entry.key;
final episodes = entry.value;
if (_searchQuery.isEmpty) {
_filteredServerDownloadsByPodcast[podcastName] = List.from(episodes);
} else {
final filteredEpisodes = episodes.where((episode) {
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
if (filteredEpisodes.isNotEmpty) {
_filteredServerDownloadsByPodcast[podcastName] = filteredEpisodes;
}
}
}
// Filter local downloads (will be called when local downloads are loaded)
_filterLocalDownloads();
}
void _filterLocalDownloads([Map<String, List<Episode>>? localDownloadsByPodcast]) {
final downloadsToFilter = localDownloadsByPodcast ?? _localDownloadsByPodcast;
_filteredLocalDownloadsByPodcast = {};
for (final entry in downloadsToFilter.entries) {
final podcastName = entry.key;
final episodes = entry.value;
if (_searchQuery.isEmpty) {
_filteredLocalDownloadsByPodcast[podcastName] = List.from(episodes);
} else {
final filteredEpisodes = episodes.where((episode) {
return (episode.title ?? '').toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
if (filteredEpisodes.isNotEmpty) {
_filteredLocalDownloadsByPodcast[podcastName] = filteredEpisodes;
}
}
}
}
Future<void> _loadDownloads() async {
await Future.wait([
_loadServerDownloads(),
_loadLocalDownloads(),
]);
}
Future<void> _loadServerDownloads() async {
setState(() {
_isLoadingServerDownloads = true;
_errorMessage = null;
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final downloads = await _pinepodsService.getServerDownloads(settings.pinepodsUserId!);
setState(() {
_serverDownloads = downloads;
_serverDownloadsByPodcast = _groupEpisodesByPodcast(downloads);
_filterDownloads(); // Initialize filtered data
_isLoadingServerDownloads = false;
});
} else {
setState(() {
_isLoadingServerDownloads = false;
});
}
} catch (e) {
log.severe('Error loading server downloads: $e');
setState(() {
_errorMessage = 'Failed to load server downloads: $e';
_isLoadingServerDownloads = false;
});
}
}
Future<void> _loadLocalDownloads() async {
setState(() {
_isLoadingLocalDownloads = true;
});
try {
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
episodeBloc.fetchDownloads(false);
// Debug: Let's also directly check what the repository returns
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
final directDownloads = await podcastBloc.podcastService.loadDownloads();
print('DEBUG: Direct downloads from repository: ${directDownloads.length} episodes');
for (var episode in directDownloads) {
print('DEBUG: Episode: ${episode.title}, GUID: ${episode.guid}, Downloaded: ${episode.downloaded}, Percentage: ${episode.downloadPercentage}');
}
setState(() {
_isLoadingLocalDownloads = false;
});
} catch (e) {
log.severe('Error loading local downloads: $e');
setState(() {
_isLoadingLocalDownloads = false;
});
}
}
Map<String, List<PinepodsEpisode>> _groupEpisodesByPodcast(List<PinepodsEpisode> episodes) {
final grouped = <String, List<PinepodsEpisode>>{};
for (final episode in episodes) {
final podcastName = episode.podcastName;
if (!grouped.containsKey(podcastName)) {
grouped[podcastName] = [];
}
grouped[podcastName]!.add(episode);
}
// Sort episodes within each podcast by publication date (newest first)
for (final episodes in grouped.values) {
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;
}
});
}
return grouped;
}
Map<String, List<Episode>> _groupLocalEpisodesByPodcast(List<Episode> episodes) {
final grouped = <String, List<Episode>>{};
for (final episode in episodes) {
final podcastName = episode.podcast ?? 'Unknown Podcast';
if (!grouped.containsKey(podcastName)) {
grouped[podcastName] = [];
}
grouped[podcastName]!.add(episode);
}
// Sort episodes within each podcast by publication date (newest first)
for (final episodes in grouped.values) {
episodes.sort((a, b) {
if (a.publicationDate == null || b.publicationDate == null) {
return 0;
}
return b.publicationDate!.compareTo(a.publicationDate!);
});
}
return grouped;
}
void _togglePodcastExpansion(String podcastKey) {
setState(() {
if (_expandedPodcasts.contains(podcastKey)) {
_expandedPodcasts.remove(podcastKey);
} else {
_expandedPodcasts.add(podcastKey);
}
});
}
Future<void> _handleServerEpisodeDelete(PinepodsEpisode episode) async {
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsUserId != null) {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
settings.pinepodsUserId!,
episode.isYoutube,
);
if (success) {
// Remove from local state
setState(() {
_serverDownloads.removeWhere((e) => e.episodeId == episode.episodeId);
_serverDownloadsByPodcast = _groupEpisodesByPodcast(_serverDownloads);
_filterDownloads(); // Update filtered lists after removal
});
} else {
_showErrorSnackBar('Failed to delete episode from server');
}
}
} catch (e) {
log.severe('Error deleting server episode: $e');
_showErrorSnackBar('Error deleting episode: $e');
}
}
void _handleLocalEpisodeDelete(Episode episode) {
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
episodeBloc.deleteDownload(episode);
// The episode bloc will automatically update the downloads stream
// which will trigger a UI refresh
}
void _showContextMenu(int episodeIndex, bool isServerEpisode) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
_isServerEpisode = isServerEpisode;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
_isServerEpisode = false;
});
}
Future<void> _localDownloadServerEpisode(int episodeIndex) async {
final episode = _serverDownloads[episodeIndex];
try {
// Convert PinepodsEpisode to Episode for local download
final localEpisode = Episode(
guid: 'pinepods_${episode.episodeId}_${DateTime.now().millisecondsSinceEpoch}',
pguid: 'pinepods_${episode.podcastName.replaceAll(' ', '_').toLowerCase()}',
podcast: episode.podcastName,
title: episode.episodeTitle,
description: episode.episodeDescription,
imageUrl: episode.episodeArtwork,
contentUrl: episode.episodeUrl,
duration: episode.episodeDuration,
publicationDate: DateTime.tryParse(episode.episodePubDate),
author: episode.podcastName,
season: 0,
episode: 0,
position: episode.listenDuration ?? 0,
played: episode.completed,
chapters: [],
transcriptUrls: [],
);
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
// First save the episode to the repository so it can be tracked
await podcastBloc.podcastService.saveEpisode(localEpisode);
// Use the download service from podcast bloc
final success = await podcastBloc.downloadService.downloadEpisode(localEpisode);
if (success) {
_showSnackBar('Episode download started', Colors.green);
} else {
_showSnackBar('Failed to start download', Colors.red);
}
} catch (e) {
_showSnackBar('Error starting local download: $e', Colors.red);
}
_hideContextMenu();
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Widget _buildPodcastDropdown(String podcastKey, List<dynamic> episodes, {bool isServerDownload = false, String? displayName}) {
final isExpanded = _expandedPodcasts.contains(podcastKey);
final title = displayName ?? podcastKey;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: Column(
children: [
ListTile(
leading: Icon(
isServerDownload ? Icons.cloud_download : Icons.file_download,
color: isServerDownload ? Colors.blue : Colors.green,
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${episodes.length} episode${episodes.length != 1 ? 's' : ''}' +
(episodes.length > 20 ? ' (showing 20 at a time)' : '')
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (episodes.length > 20)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Large',
style: TextStyle(
fontSize: 10,
color: Colors.orange[800],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
),
],
),
onTap: () => _togglePodcastExpansion(podcastKey),
),
if (isExpanded)
PaginatedEpisodeList(
episodes: episodes,
isServerEpisodes: isServerDownload,
onEpisodeTap: isServerDownload
? (episode) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
}
: null,
onEpisodeLongPress: isServerDownload
? (episode, globalIndex) {
// Find the index in the full _serverDownloads list
final serverIndex = _serverDownloads.indexWhere((e) => e.episodeId == episode.episodeId);
_showContextMenu(serverIndex >= 0 ? serverIndex : globalIndex, true);
}
: null,
onPlayPressed: isServerDownload
? (episode) => _playServerEpisode(episode)
: (episode) => _playLocalEpisode(episode),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final episodeBloc = Provider.of<EpisodeBloc>(context);
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isServerEpisode) {
// Show server episode context menu
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: _serverDownloads[episodeIndex],
onDownload: () {
Navigator.of(context).pop();
_handleServerEpisodeDelete(_serverDownloads[episodeIndex]);
_hideContextMenu();
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadServerEpisode(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
_hideContextMenu();
},
),
);
}
});
// Reset the context menu index after storing it locally
_contextMenuEpisodeIndex = null;
}
return StreamBuilder<BlocState>(
stream: episodeBloc.downloads,
builder: (context, snapshot) {
final localDownloadsState = snapshot.data;
List<Episode> currentLocalDownloads = [];
Map<String, List<Episode>> currentLocalDownloadsByPodcast = {};
if (localDownloadsState is BlocPopulatedState<List<Episode>>) {
currentLocalDownloads = localDownloadsState.results ?? [];
currentLocalDownloadsByPodcast = _groupLocalEpisodesByPodcast(currentLocalDownloads);
}
final isLoading = _isLoadingServerDownloads ||
_isLoadingLocalDownloads ||
(localDownloadsState is BlocLoadingState);
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: PlatformProgressIndicator()),
);
}
// Update filtered local downloads when local downloads change
_filterLocalDownloads(currentLocalDownloadsByPodcast);
if (_errorMessage != null) {
// Check if this is a server connection error - show offline mode for downloads
if (_errorMessage!.isServerConnectionError) {
// Show offline downloads only with special UI
return _buildOfflineDownloadsView(_filteredLocalDownloadsByPodcast);
} else {
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
_errorMessage!.userFriendlyMessage,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadDownloads,
child: const Text('Retry'),
),
],
),
),
);
}
}
if (_filteredLocalDownloadsByPodcast.isEmpty && _filteredServerDownloadsByPodcast.isEmpty) {
if (_searchQuery.isNotEmpty) {
// Show no search results message
return MultiSliver(
children: [
_buildSearchBar(),
SliverFillRemaining(
hasScrollBody: false,
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 downloads found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'No downloads match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
],
);
} else {
// Show empty downloads message
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.download_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No downloads found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Downloaded episodes will appear here',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
}
return MultiSliver(
children: [
_buildSearchBar(),
_buildDownloadsList(),
],
);
},
);
}
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 _buildDownloadsList() {
return SliverList(
delegate: SliverChildListDelegate([
// Local Downloads Section
if (_filteredLocalDownloadsByPodcast.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.smartphone, color: Colors.green[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Local Downloads'
: 'Local Downloads (${_countFilteredEpisodes(_filteredLocalDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.green[600],
),
),
],
),
),
..._filteredLocalDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'local_$podcastName';
return _buildPodcastDropdown(
podcastKey,
episodes,
isServerDownload: false,
displayName: podcastName,
);
}).toList(),
],
// Server Downloads Section
if (_filteredServerDownloadsByPodcast.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
children: [
Icon(Icons.cloud_download, color: Colors.blue[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Server Downloads'
: 'Server Downloads (${_countFilteredEpisodes(_filteredServerDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue[600],
),
),
],
),
),
..._filteredServerDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'server_$podcastName';
return _buildPodcastDropdown(
podcastKey,
episodes,
isServerDownload: true,
displayName: podcastName,
);
}).toList(),
],
// Bottom padding
const SizedBox(height: 100),
]),
);
}
int _countFilteredEpisodes(Map<String, List<dynamic>> downloadsByPodcast) {
return downloadsByPodcast.values.fold(0, (sum, episodes) => sum + episodes.length);
}
void _playServerEpisode(PinepodsEpisode episode) {
// TODO: Implement server episode playback
// This would involve getting the stream URL from the server
// and playing it through the audio service
log.info('Playing server episode: ${episode.episodeTitle}');
_showErrorSnackBar('Server episode playback not yet implemented');
}
Future<void> _playLocalEpisode(Episode episode) async {
try {
log.info('Playing local episode: ${episode.title}');
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Use the regular audio player service for offline playback
// This bypasses the PinePods service and server dependencies
await audioPlayerService.playEpisode(episode: episode, resume: true);
log.info('Successfully started local episode playback');
} catch (e) {
log.severe('Error playing local episode: $e');
_showErrorSnackBar('Failed to play episode: $e');
}
}
Widget _buildOfflinePodcastDropdown(String podcastKey, List<Episode> episodes, {String? displayName}) {
final isExpanded = _expandedPodcasts.contains(podcastKey);
final title = displayName ?? podcastKey;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: Column(
children: [
ListTile(
leading: Icon(
Icons.offline_pin,
color: Colors.green[700],
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${episodes.length} episode${episodes.length != 1 ? 's' : ''} available offline'
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Offline',
style: TextStyle(
fontSize: 10,
color: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
),
],
),
onTap: () => _togglePodcastExpansion(podcastKey),
),
if (isExpanded)
PaginatedEpisodeList(
episodes: episodes,
isServerEpisodes: false,
isOfflineMode: true,
onPlayPressed: (episode) => _playLocalEpisode(episode),
),
],
),
);
}
Widget _buildOfflineDownloadsView(Map<String, List<Episode>> localDownloadsByPodcast) {
return MultiSliver(
children: [
// Offline banner
SliverToBoxAdapter(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
margin: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Colors.orange[100],
border: Border.all(color: Colors.orange[300]!),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.cloud_off,
color: Colors.orange[800],
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Offline Mode',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange[800],
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
'Server unavailable. Showing local downloads only.',
style: TextStyle(
color: Colors.orange[700],
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: () {
setState(() {
_errorMessage = null;
});
_loadDownloads();
},
icon: Icon(
Icons.refresh,
size: 16,
color: Colors.orange[800],
),
label: Text(
'Retry',
style: TextStyle(
color: Colors.orange[800],
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange[50],
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
),
),
// Search bar for filtering local downloads
_buildSearchBar(),
// Local downloads content
if (localDownloadsByPodcast.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No local downloads',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Download episodes while online to access them here',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverList(
delegate: SliverChildListDelegate([
// Local downloads header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.smartphone, color: Colors.green[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Local Downloads'
: 'Local Downloads (${_countFilteredEpisodes(localDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.green[600],
),
),
],
),
),
// Local downloads by podcast
...localDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'offline_local_$podcastName';
return _buildOfflinePodcastDropdown(
podcastKey,
episodes,
displayName: podcastName,
);
}).toList(),
// Bottom padding
const SizedBox(height: 100),
]),
),
],
);
}
}