added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,390 @@
// lib/ui/pinepods/create_playlist.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:provider/provider.dart';
class CreatePlaylistPage extends StatefulWidget {
const CreatePlaylistPage({Key? key}) : super(key: key);
@override
State<CreatePlaylistPage> createState() => _CreatePlaylistPageState();
}
class _CreatePlaylistPageState extends State<CreatePlaylistPage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final PinepodsService _pinepodsService = PinepodsService();
bool _isLoading = false;
String _selectedIcon = 'ph-playlist';
bool _includeUnplayed = true;
bool _includePartiallyPlayed = true;
bool _includePlayed = false;
String _minDuration = '';
String _maxDuration = '';
String _sortOrder = 'newest_first';
bool _groupByPodcast = false;
String _maxEpisodes = '';
final List<Map<String, String>> _availableIcons = [
{'name': 'ph-playlist', 'icon': '🎵'},
{'name': 'ph-music-notes', 'icon': '🎶'},
{'name': 'ph-play-circle', 'icon': '▶️'},
{'name': 'ph-headphones', 'icon': '🎧'},
{'name': 'ph-star', 'icon': ''},
{'name': 'ph-heart', 'icon': '❤️'},
{'name': 'ph-bookmark', 'icon': '🔖'},
{'name': 'ph-clock', 'icon': ''},
{'name': 'ph-calendar', 'icon': '📅'},
{'name': 'ph-timer', 'icon': '⏲️'},
{'name': 'ph-shuffle', 'icon': '🔀'},
{'name': 'ph-repeat', 'icon': '🔁'},
{'name': 'ph-microphone', 'icon': '🎤'},
{'name': 'ph-queue', 'icon': '📋'},
{'name': 'ph-fire', 'icon': '🔥'},
{'name': 'ph-lightning', 'icon': ''},
{'name': 'ph-coffee', 'icon': ''},
{'name': 'ph-moon', 'icon': '🌙'},
{'name': 'ph-sun', 'icon': '☀️'},
{'name': 'ph-rocket', 'icon': '🚀'},
];
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _createPlaylist() async {
if (!_formKey.currentState!.validate()) {
return;
}
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not connected to PinePods server')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final request = CreatePlaylistRequest(
userId: settings.pinepodsUserId!,
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isNotEmpty
? _descriptionController.text.trim()
: null,
podcastIds: const [], // For now, we'll create without podcast filtering
includeUnplayed: _includeUnplayed,
includePartiallyPlayed: _includePartiallyPlayed,
includePlayed: _includePlayed,
minDuration: _minDuration.isNotEmpty ? int.tryParse(_minDuration) : null,
maxDuration: _maxDuration.isNotEmpty ? int.tryParse(_maxDuration) : null,
sortOrder: _sortOrder,
groupByPodcast: _groupByPodcast,
maxEpisodes: _maxEpisodes.isNotEmpty ? int.tryParse(_maxEpisodes) : null,
iconName: _selectedIcon,
playProgressMin: null, // Simplified for now
playProgressMax: null,
timeFilterHours: null,
);
final success = await _pinepodsService.createPlaylist(request);
if (success) {
if (mounted) {
Navigator.of(context).pop(true); // Return true to indicate success
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlist created successfully!')),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to create playlist')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error creating playlist: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Create Playlist'),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
TextButton(
onPressed: _createPlaylist,
child: const Text('Create'),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Name field
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Playlist Name',
border: OutlineInputBorder(),
hintText: 'Enter playlist name',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a playlist name';
}
return null;
},
),
const SizedBox(height: 16),
// Description field
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (Optional)',
border: OutlineInputBorder(),
hintText: 'Enter playlist description',
),
maxLines: 3,
),
const SizedBox(height: 16),
// Icon selector
Text(
'Icon',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Container(
height: 120,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _availableIcons.length,
itemBuilder: (context, index) {
final icon = _availableIcons[index];
final isSelected = _selectedIcon == icon['name'];
return GestureDetector(
onTap: () {
setState(() {
_selectedIcon = icon['name']!;
});
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.2)
: null,
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
icon['icon']!,
style: const TextStyle(fontSize: 20),
),
),
),
);
},
),
),
const SizedBox(height: 24),
Text(
'Episode Filters',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Episode filters
CheckboxListTile(
title: const Text('Include Unplayed'),
value: _includeUnplayed,
onChanged: (value) {
setState(() {
_includeUnplayed = value ?? true;
});
},
),
CheckboxListTile(
title: const Text('Include Partially Played'),
value: _includePartiallyPlayed,
onChanged: (value) {
setState(() {
_includePartiallyPlayed = value ?? true;
});
},
),
CheckboxListTile(
title: const Text('Include Played'),
value: _includePlayed,
onChanged: (value) {
setState(() {
_includePlayed = value ?? false;
});
},
),
const SizedBox(height: 16),
// Duration range
Text(
'Duration Range (minutes)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Min',
border: OutlineInputBorder(),
hintText: 'Any',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_minDuration = value;
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Max',
border: OutlineInputBorder(),
hintText: 'Any',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_maxDuration = value;
},
),
),
],
),
const SizedBox(height: 16),
// Sort order
DropdownButtonFormField<String>(
value: _sortOrder,
decoration: const InputDecoration(
labelText: 'Sort Order',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'newest_first', child: Text('Newest First')),
DropdownMenuItem(value: 'oldest_first', child: Text('Oldest First')),
DropdownMenuItem(value: 'shortest_first', child: Text('Shortest First')),
DropdownMenuItem(value: 'longest_first', child: Text('Longest First')),
],
onChanged: (value) {
setState(() {
_sortOrder = value!;
});
},
),
const SizedBox(height: 16),
// Max episodes
TextFormField(
decoration: const InputDecoration(
labelText: 'Max Episodes (Optional)',
border: OutlineInputBorder(),
hintText: 'Leave blank for no limit',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_maxEpisodes = value;
},
),
const SizedBox(height: 16),
// Group by podcast
CheckboxListTile(
title: const Text('Group by Podcast'),
subtitle: const Text('Group episodes by their podcast'),
value: _groupByPodcast,
onChanged: (value) {
setState(() {
_groupByPodcast = value ?? false;
});
},
),
const SizedBox(height: 32),
],
),
),
);
}
}

View File

@@ -0,0 +1,968 @@
// 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),
]),
),
],
);
}
}

View File

@@ -0,0 +1,963 @@
// lib/ui/pinepods/episode_details.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/services/audio/default_audio_player_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:pinepods_mobile/ui/widgets/episode_description.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/podcast/mini_player.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
class PinepodsEpisodeDetails extends StatefulWidget {
final PinepodsEpisode initialEpisode;
const PinepodsEpisodeDetails({
Key? key,
required this.initialEpisode,
}) : super(key: key);
@override
State<PinepodsEpisodeDetails> createState() => _PinepodsEpisodeDetailsState();
}
class _PinepodsEpisodeDetailsState extends State<PinepodsEpisodeDetails> {
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
PinepodsEpisode? _episode;
bool _isLoading = true;
String _errorMessage = '';
List<Person> _persons = [];
bool _isDownloadedLocally = false;
@override
void initState() {
super.initState();
_episode = widget.initialEpisode;
_loadEpisodeDetails();
_checkLocalDownloadStatus();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _checkLocalDownloadStatus() async {
if (_episode == null) return;
final isDownloaded = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, _episode!);
if (mounted) {
setState(() {
_isDownloadedLocally = isDownloaded;
});
}
}
Future<void> _localDownloadEpisode() async {
if (_episode == null) return;
final success = await LocalDownloadUtils.localDownloadEpisode(context, _episode!);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
await _checkLocalDownloadStatus(); // Update button state
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload() async {
if (_episode == null) return;
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, _episode!);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
await _checkLocalDownloadStatus(); // Update button state
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
Future<void> _loadEpisodeDetails() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(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 episodeDetails = await _pinepodsService.getEpisodeMetadata(
_episode!.episodeId,
userId,
isYoutube: _episode!.isYoutube,
personEpisode: false, // Adjust if needed
);
if (episodeDetails != null) {
// Fetch podcast 2.0 data for persons information
final podcast2Data = await _pinepodsService.fetchPodcasting2Data(
episodeDetails.episodeId,
userId,
);
List<Person> persons = [];
if (podcast2Data != null) {
final personsData = podcast2Data['people'] as List<dynamic>?;
if (personsData != null) {
try {
persons = personsData.map((personData) {
return Person(
name: personData['name'] ?? '',
role: personData['role'] ?? '',
group: personData['group'] ?? '',
image: personData['img'],
link: personData['href'],
);
}).toList();
print('Loaded ${persons.length} persons from episode 2.0 data');
} catch (e) {
print('Error parsing persons data: $e');
}
}
}
setState(() {
_episode = episodeDetails;
_persons = persons;
_isLoading = false;
});
} else {
setState(() {
_errorMessage = 'Failed to load episode details';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_errorMessage = 'Error loading episode details: ${e.toString()}';
_isLoading = false;
});
}
}
bool _isCurrentEpisodePlaying() {
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
final currentEpisode = audioPlayerService.nowPlaying;
return currentEpisode != null && currentEpisode.guid == _episode!.episodeUrl;
} catch (e) {
return false;
}
}
bool _isAudioPlaying() {
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// This method is no longer needed since we're using StreamBuilder
return false;
} catch (e) {
return false;
}
}
Future<void> _togglePlayPause() async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Check if this episode is currently playing
if (_isCurrentEpisodePlaying()) {
// This episode is loaded, check current state and toggle
final currentState = audioPlayerService.playingState;
if (currentState != null) {
// Listen to the current state
final state = await currentState.first;
if (state == AudioState.playing) {
await audioPlayerService.pause();
} else {
await audioPlayerService.play();
}
} else {
await audioPlayerService.play();
}
} else {
// Start playing this episode
await playPinepodsEpisodeWithOptionalFullScreen(
context,
_audioService!,
_episode!,
resume: _episode!.isStarted,
);
}
} catch (e) {
_showSnackBar('Failed to control playback: ${e.toString()}', Colors.red);
}
}
Future<void> _handleTimestampTap(Duration timestamp) async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Check if this episode is currently playing
final currentEpisode = audioPlayerService.nowPlaying;
final isCurrentEpisode = currentEpisode != null &&
currentEpisode.guid == _episode!.episodeUrl;
if (!isCurrentEpisode) {
// Start playing the episode first
await playPinepodsEpisodeWithOptionalFullScreen(
context,
_audioService!,
_episode!,
resume: false, // Start from beginning initially
);
// Wait a moment for the episode to start loading
await Future.delayed(const Duration(milliseconds: 500));
}
// Seek to the timestamp (convert Duration to seconds as int)
await audioPlayerService.seek(position: timestamp.inSeconds);
} catch (e) {
_showSnackBar('Failed to jump to timestamp: ${e.toString()}', Colors.red);
}
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
Future<void> _saveEpisode() async {
final settingsBloc = Provider.of<SettingsBloc>(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) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, saved: true);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
Future<void> _removeSavedEpisode() async {
final settingsBloc = Provider.of<SettingsBloc>(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) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, saved: false);
});
_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);
}
}
Future<void> _toggleQueue() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.queued) {
success = await _pinepodsService.removeQueuedEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, queued: false);
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
success = await _pinepodsService.queueEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, queued: true);
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
}
Future<void> _toggleDownload() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.downloaded) {
success = await _pinepodsService.deleteEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, downloaded: false);
});
_showSnackBar('Episode deleted from server', Colors.orange);
}
} else {
success = await _pinepodsService.downloadEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, downloaded: true);
});
_showSnackBar('Episode download queued!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update download', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating download: $e', Colors.red);
}
}
Future<void> _toggleComplete() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, completed: false);
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, completed: true);
});
_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);
}
}
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,
podcastId: episode.podcastId,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Future<void> _navigateToPodcast() async {
if (_episode!.podcastId == null) {
_showSnackBar('Podcast ID not available', Colors.orange);
return;
}
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
// Fetch the actual podcast details to get correct episode count
final podcastDetails = await _pinepodsService.getPodcastDetailsById(_episode!.podcastId!, userId);
final podcast = UnifiedPinepodsPodcast(
id: _episode!.podcastId!,
indexId: 0,
title: _episode!.podcastName,
url: podcastDetails?['feedurl'] ?? '',
originalUrl: podcastDetails?['feedurl'] ?? '',
link: podcastDetails?['websiteurl'] ?? '',
description: podcastDetails?['description'] ?? '',
author: podcastDetails?['author'] ?? '',
ownerName: podcastDetails?['author'] ?? '',
image: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork,
artwork: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork,
lastUpdateTime: 0,
explicit: podcastDetails?['explicit'] ?? false,
episodeCount: podcastDetails?['episodecount'] ?? 0,
);
// Navigate to podcast details - same as podcast tile does
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: podcast,
isFollowing: true, // Assume following since we have a podcast ID
),
),
);
} catch (e) {
_showSnackBar('Error navigating to podcast: $e', Colors.red);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(
title: const Text('Episode Details'),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading episode details...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return Scaffold(
appBar: AppBar(
title: const Text('Episode Details'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 48,
),
const SizedBox(height: 16),
Text(
_errorMessage,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadEpisodeDetails,
child: const Text('Retry'),
),
],
),
),
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_episode!.podcastName),
elevation: 0,
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode artwork and basic info
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode artwork
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _episode!.episodeArtwork.isNotEmpty
? Image.network(
_episode!.episodeArtwork,
width: 120,
height: 120,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 48,
),
);
},
)
: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 48,
),
),
),
const SizedBox(width: 16),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Clickable podcast name
GestureDetector(
onTap: () => _navigateToPodcast(),
child: Text(
_episode!.podcastName,
style: Theme.of(context).textTheme.titleMedium!.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: Theme.of(context).primaryColor,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 4),
Text(
_episode!.episodeTitle,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
_episode!.formattedDuration,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
_episode!.formattedPubDate,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.grey[600],
),
),
if (_episode!.isStarted) ...[
const SizedBox(height: 8),
Text(
'Listened: ${_episode!.formattedListenDuration}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).primaryColor,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: _episode!.progressPercentage / 100,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
),
],
],
),
),
],
),
const SizedBox(height: 24),
// Action buttons
Column(
children: [
// First row: Play, Save, Queue (3 buttons, each 1/3 width)
Row(
children: [
// Play/Pause button
Expanded(
child: StreamBuilder<AudioState>(
stream: Provider.of<AudioPlayerService>(context, listen: false).playingState,
builder: (context, snapshot) {
final isCurrentEpisode = _isCurrentEpisodePlaying();
final isPlaying = snapshot.data == AudioState.playing;
final isCurrentlyPlaying = isCurrentEpisode && isPlaying;
IconData icon;
String label;
if (_episode!.completed) {
icon = Icons.replay;
label = 'Replay';
} else if (isCurrentlyPlaying) {
icon = Icons.pause;
label = 'Pause';
} else {
icon = Icons.play_arrow;
label = 'Play';
}
return OutlinedButton.icon(
onPressed: _togglePlayPause,
icon: Icon(icon),
label: Text(label),
);
},
),
),
const SizedBox(width: 8),
// Save/Unsave button
Expanded(
child: OutlinedButton.icon(
onPressed: _episode!.saved ? _removeSavedEpisode : _saveEpisode,
icon: Icon(
_episode!.saved ? Icons.bookmark : Icons.bookmark_outline,
color: _episode!.saved ? Colors.orange : null,
),
label: Text(_episode!.saved ? 'Saved' : 'Save'),
),
),
const SizedBox(width: 8),
// Queue button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleQueue,
icon: Icon(
_episode!.queued ? Icons.queue_music : Icons.queue_music_outlined,
color: _episode!.queued ? Colors.purple : null,
),
label: Text(_episode!.queued ? 'Queued' : 'Queue'),
),
),
],
),
const SizedBox(height: 8),
// Second row: Download, Complete (2 buttons, each 1/2 width)
Row(
children: [
// Download button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleDownload,
icon: Icon(
_episode!.downloaded ? Icons.download_done : Icons.download_outlined,
color: _episode!.downloaded ? Colors.blue : null,
),
label: Text(_episode!.downloaded ? 'Downloaded' : 'Download'),
),
),
const SizedBox(width: 8),
// Complete button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleComplete,
icon: Icon(
_episode!.completed ? Icons.check_circle : Icons.check_circle_outline,
color: _episode!.completed ? Colors.green : null,
),
label: Text(_episode!.completed ? 'Complete' : 'Mark Complete'),
),
),
],
),
const SizedBox(height: 8),
// Third row: Local Download (full width)
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isDownloadedLocally ? _deleteLocalDownload : _localDownloadEpisode,
icon: Icon(
_isDownloadedLocally ? Icons.delete_forever_outlined : Icons.file_download_outlined,
color: _isDownloadedLocally ? Colors.red : Colors.green,
),
label: Text(_isDownloadedLocally ? 'Delete Local Download' : 'Download Locally'),
style: OutlinedButton.styleFrom(
side: BorderSide(
color: _isDownloadedLocally ? Colors.red : Colors.green,
),
),
),
),
],
),
],
),
// Hosts/Guests section
if (_persons.isNotEmpty) ...[
const SizedBox(height: 24),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Hosts & Guests',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
SizedBox(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _persons.length,
itemBuilder: (context, index) {
final person = _persons[index];
return Container(
width: 70,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey[300],
),
child: person.image != null && person.image!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(25),
child: PodcastImage(
url: person.image!,
width: 50,
height: 50,
fit: BoxFit.cover,
),
)
: const Icon(
Icons.person,
size: 30,
color: Colors.grey,
),
),
const SizedBox(height: 4),
Text(
person.name,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
},
),
),
],
const SizedBox(height: 32),
// Episode description
Text(
'Description',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
EpisodeDescription(
content: _episode!.episodeDescription,
onTimestampTap: _handleTimestampTap,
),
],
),
),
),
const MiniPlayer(),
],
),
);
}
@override
void dispose() {
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
}

View File

@@ -0,0 +1,817 @@
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<EpisodeSearchPage> createState() => _EpisodeSearchPageState();
}
class _EpisodeSearchPageState extends State<EpisodeSearchPage> with TickerProviderStateMixin {
final PinepodsService _pinepodsService = PinepodsService();
final SearchHistoryService _searchHistoryService = SearchHistoryService();
final TextEditingController _searchController = TextEditingController();
final FocusNode _focusNode = FocusNode();
Timer? _debounceTimer;
List<SearchEpisodeResult> _searchResults = [];
List<String> _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<double> _fadeAnimation;
late Animation<Offset> _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<double>(
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<Offset>(
begin: const Offset(0, 0),
end: const Offset(0, -0.2),
).animate(CurvedAnimation(
parent: _slideAnimationController,
curve: Curves.easeInOut,
));
}
void _setupSearch() {
final settingsBloc = Provider.of<SettingsBloc>(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<void> _loadSearchHistory() async {
final history = await _searchHistoryService.getEpisodeSearchHistory();
if (mounted) {
setState(() {
_searchHistory = history;
});
}
}
void _selectHistoryItem(String searchTerm) {
_searchController.text = searchTerm;
_performSearch(searchTerm);
}
Future<void> _removeHistoryItem(String searchTerm) async {
await _searchHistoryService.removeEpisodeSearchTerm(searchTerm);
await _loadSearchHistory();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _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<void> _saveEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(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<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(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<void> _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<void> _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<void> _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<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(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<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(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<void> _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<SettingsBloc>(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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,745 @@
// 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<PinepodsHistory> createState() => _PinepodsHistoryState();
}
class _PinepodsHistoryState extends State<PinepodsHistory> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
List<PinepodsEpisode> _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<void> _loadHistory() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(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<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadHistory();
}
Future<void> _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<void> _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<void> _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<void> _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<void> _saveEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
// lib/ui/pinepods/more_menu.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/ui/library/downloads.dart';
import 'package:pinepods_mobile/ui/settings/settings.dart';
import 'package:pinepods_mobile/ui/pinepods/saved.dart';
import 'package:pinepods_mobile/ui/pinepods/history.dart';
class PinepodsMoreMenu extends StatelessWidget {
// Constructor with optional key parameter
const PinepodsMoreMenu({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'More Options',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildMenuItem(
context,
'Downloads',
Icons.download_outlined,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Downloads')),
body: const CustomScrollView(
slivers: [Downloads()],
),
),
),
),
),
_buildMenuItem(
context,
'Saved Episodes',
Icons.bookmark_outline,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Saved Episodes')),
body: const CustomScrollView(
slivers: [PinepodsSaved()],
),
),
),
),
),
_buildMenuItem(
context,
'History',
Icons.history,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('History')),
body: const CustomScrollView(
slivers: [PinepodsHistory()],
),
),
),
),
),
_buildMenuItem(
context,
'Settings',
Icons.settings_outlined,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: true,
settings: const RouteSettings(name: 'settings'),
builder: (context) => const Settings(),
),
),
),
],
),
),
]),
);
}
Widget _buildMenuItem(
BuildContext context,
String title,
IconData icon,
VoidCallback onTap,
) {
return Card(
margin: const EdgeInsets.only(bottom: 12.0),
child: ListTile(
leading: Icon(icon),
title: Text(title),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap,
),
);
}
}

View File

@@ -0,0 +1,572 @@
// lib/ui/pinepods/playlist_episodes.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/services/global_services.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.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/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
class PlaylistEpisodesPage extends StatefulWidget {
final PlaylistData playlist;
const PlaylistEpisodesPage({
Key? key,
required this.playlist,
}) : super(key: key);
@override
State<PlaylistEpisodesPage> createState() => _PlaylistEpisodesPageState();
}
class _PlaylistEpisodesPageState extends State<PlaylistEpisodesPage> {
final PinepodsService _pinepodsService = PinepodsService();
PlaylistEpisodesResponse? _playlistResponse;
bool _isLoading = true;
String? _errorMessage;
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
@override
void initState() {
super.initState();
_loadPlaylistEpisodes();
}
Future<void> _loadPlaylistEpisodes() async {
final settingsBloc = Provider.of<SettingsBloc>(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 connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final response = await _pinepodsService.getPlaylistEpisodes(
settings.pinepodsUserId!,
widget.playlist.playlistId,
);
setState(() {
_playlistResponse = response;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
IconData _getPlaylistIcon(String? iconName) {
if (iconName == null) return Icons.playlist_play;
// Map common icon names to Material icons
switch (iconName) {
case 'ph-playlist':
return Icons.playlist_play;
case 'ph-music-notes':
return Icons.music_note;
case 'ph-play-circle':
return Icons.play_circle;
case 'ph-headphones':
return Icons.headphones;
case 'ph-star':
return Icons.star;
case 'ph-heart':
return Icons.favorite;
case 'ph-bookmark':
return Icons.bookmark;
case 'ph-clock':
return Icons.access_time;
case 'ph-calendar':
return Icons.calendar_today;
case 'ph-timer':
return Icons.timer;
case 'ph-shuffle':
return Icons.shuffle;
case 'ph-repeat':
return Icons.repeat;
case 'ph-microphone':
return Icons.mic;
case 'ph-queue':
return Icons.queue_music;
default:
return Icons.playlist_play;
}
}
String _getEmptyStateMessage() {
switch (widget.playlist.name) {
case 'Fresh Releases':
return 'No new episodes have been released in the last 24 hours. Check back later for fresh content!';
case 'Currently Listening':
return 'Start listening to some episodes and they\'ll appear here for easy access.';
case 'Almost Done':
return 'You don\'t have any episodes that are near completion. Keep listening!';
default:
return 'No episodes match the current playlist criteria. Try adjusting the filters or add more podcasts.';
}
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode);
} catch (e) {
if (mounted) {
_showSnackBar('Failed to play episode: $e', Colors.red);
}
}
}
void _showContextMenu(int episodeIndex) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
} else if (mounted) {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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<void> _downloadEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual download implementation would depend on download service integration
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange);
// Note: Actual delete implementation would depend on download service integration
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual local download implementation would depend on download service integration
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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),
),
);
}
}
@override
Widget build(BuildContext context) {
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!;
final episode = _playlistResponse!.episodes[episodeIndex];
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 Scaffold(
appBar: AppBar(
title: Text(widget.playlist.name),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
PlatformProgressIndicator(),
SizedBox(height: 16),
Text('Loading playlist episodes...'),
],
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 75,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading playlist',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPlaylistEpisodes,
child: const Text('Retry'),
),
],
),
),
);
}
if (_playlistResponse == null) {
return const Center(
child: Text('No data available'),
);
}
return CustomScrollView(
slivers: [
// Playlist header
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getPlaylistIcon(_playlistResponse!.playlistInfo.iconName),
size: 48,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_playlistResponse!.playlistInfo.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (_playlistResponse!.playlistInfo.description != null &&
_playlistResponse!.playlistInfo.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_playlistResponse!.playlistInfo.description!,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${_playlistResponse!.playlistInfo.episodeCount ?? _playlistResponse!.episodes.length} episodes',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
],
),
),
],
),
],
),
),
),
// Episodes list
if (_playlistResponse!.episodes.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.playlist_remove,
size: 75,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'No Episodes Found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_getEmptyStateMessage(),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = _playlistResponse!.episodes[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
child: PinepodsEpisodeCard(
episode: episode,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(index),
onPlayPressed: () => _playEpisode(episode),
),
);
},
childCount: _playlistResponse!.episodes.length,
),
),
],
);
}
}

View File

@@ -0,0 +1,546 @@
// lib/ui/pinepods/playlists.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/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/ui/pinepods/playlist_episodes.dart';
import 'package:pinepods_mobile/ui/pinepods/create_playlist.dart';
import 'package:provider/provider.dart';
class PinepodsPlaylists extends StatefulWidget {
const PinepodsPlaylists({Key? key}) : super(key: key);
@override
State<PinepodsPlaylists> createState() => _PinepodsPlaylistsState();
}
class _PinepodsPlaylistsState extends State<PinepodsPlaylists> {
final PinepodsService _pinepodsService = PinepodsService();
List<PlaylistData>? _playlists;
bool _isLoading = true;
String? _errorMessage;
Set<int> _selectedPlaylists = {};
bool _isSelectionMode = false;
@override
void initState() {
super.initState();
_loadPlaylists();
}
/// Calculate responsive cross axis count for playlist grid
int _getPlaylistCrossAxisCount(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
if (screenWidth > 800) return 3; // Wide tablets like iPad
if (screenWidth > 500) return 2; // Standard phones and small tablets
return 1; // Very small phones (< 500px)
}
/// Calculate responsive aspect ratio for playlist cards
double _getPlaylistAspectRatio(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth <= 500) {
// Single column on small screens - generous height for multi-line descriptions + padding
return 1.8; // Allows space for title + 2-3 lines of description + proper padding
}
return 1.1; // Standard aspect ratio for multi-column layouts
}
Future<void> _loadPlaylists() async {
final settingsBloc = Provider.of<SettingsBloc>(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 connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final playlists = await _pinepodsService.getUserPlaylists(settings.pinepodsUserId!);
setState(() {
_playlists = playlists;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
void _toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedPlaylists.clear();
}
});
}
void _togglePlaylistSelection(int playlistId) {
setState(() {
if (_selectedPlaylists.contains(playlistId)) {
_selectedPlaylists.remove(playlistId);
} else {
_selectedPlaylists.add(playlistId);
}
});
}
Future<void> _deleteSelectedPlaylists() async {
if (_selectedPlaylists.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Playlists'),
content: Text('Are you sure you want to delete ${_selectedPlaylists.length} playlist${_selectedPlaylists.length == 1 ? '' : 's'}?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
try {
for (final playlistId in _selectedPlaylists) {
await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlistId);
}
setState(() {
_selectedPlaylists.clear();
_isSelectionMode = false;
});
_loadPlaylists(); // Refresh the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlists deleted successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error deleting playlists: $e')),
);
}
}
}
Future<void> _deletePlaylist(PlaylistData playlist) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Playlist'),
content: Text('Are you sure you want to delete "${playlist.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
try {
await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlist.playlistId);
_loadPlaylists(); // Refresh the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlist deleted successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error deleting playlist: $e')),
);
}
}
}
void _openPlaylist(PlaylistData playlist) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistEpisodesPage(playlist: playlist),
),
);
}
void _createPlaylist() async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CreatePlaylistPage(),
),
);
if (result == true) {
_loadPlaylists(); // Refresh the list
}
}
IconData _getPlaylistIcon(String? iconName) {
if (iconName == null) return Icons.playlist_play;
// Map common icon names to Material icons
switch (iconName) {
case 'ph-playlist':
return Icons.playlist_play;
case 'ph-music-notes':
return Icons.music_note;
case 'ph-play-circle':
return Icons.play_circle;
case 'ph-headphones':
return Icons.headphones;
case 'ph-star':
return Icons.star;
case 'ph-heart':
return Icons.favorite;
case 'ph-bookmark':
return Icons.bookmark;
case 'ph-clock':
return Icons.access_time;
case 'ph-calendar':
return Icons.calendar_today;
case 'ph-timer':
return Icons.timer;
case 'ph-shuffle':
return Icons.shuffle;
case 'ph-repeat':
return Icons.repeat;
case 'ph-microphone':
return Icons.mic;
case 'ph-queue':
return Icons.queue_music;
default:
return Icons.playlist_play;
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
}
if (_errorMessage != null) {
return SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: _loadPlaylists,
title: 'Playlists Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load your playlists',
);
}
if (_playlists == null || _playlists!.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.playlist_play,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No playlists found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Create a smart playlist to get started!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _createPlaylist,
icon: const Icon(Icons.add),
label: const Text('Create Playlist'),
),
],
),
),
);
}
return SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with action buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Smart Playlists',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
if (_isSelectionMode) ...[
IconButton(
icon: const Icon(Icons.close),
onPressed: _toggleSelectionMode,
tooltip: 'Cancel',
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: _selectedPlaylists.isNotEmpty ? _deleteSelectedPlaylists : null,
tooltip: 'Delete selected (${_selectedPlaylists.length})',
),
] else ...[
IconButton(
icon: const Icon(Icons.select_all),
onPressed: _toggleSelectionMode,
tooltip: 'Select multiple',
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _createPlaylist,
tooltip: 'Create playlist',
),
],
],
),
],
),
// Info banner for selection mode
if (_isSelectionMode)
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).primaryColor.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'System playlists cannot be deleted.',
style: TextStyle(fontSize: 14),
),
),
],
),
),
const SizedBox(height: 8),
// Playlists grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _getPlaylistCrossAxisCount(context),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: _getPlaylistAspectRatio(context),
),
itemCount: _playlists!.length,
itemBuilder: (context, index) {
final playlist = _playlists![index];
final isSelected = _selectedPlaylists.contains(playlist.playlistId);
final canSelect = _isSelectionMode && !playlist.isSystemPlaylist;
return GestureDetector(
onTap: () {
if (_isSelectionMode && !playlist.isSystemPlaylist) {
_togglePlaylistSelection(playlist.playlistId);
} else if (!_isSelectionMode) {
_openPlaylist(playlist);
}
},
child: Card(
elevation: isSelected ? 8 : 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.1)
: null,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getPlaylistIcon(playlist.iconName),
size: 32,
color: Theme.of(context).primaryColor,
),
const Spacer(),
if (playlist.isSystemPlaylist)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'System',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.secondary,
),
),
),
],
),
const SizedBox(height: 12),
Text(
playlist.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${playlist.episodeCount ?? 0} episodes',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
if (playlist.description != null && playlist.description!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
playlist.description!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.bodySmall?.color,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Selection checkbox
if (canSelect)
Positioned(
top: 8,
left: 8,
child: Checkbox(
value: isSelected,
onChanged: (value) {
_togglePlaylistSelection(playlist.playlistId);
},
),
),
// Delete button for non-system playlists (when not in selection mode)
if (!_isSelectionMode && !playlist.isSystemPlaylist)
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
onPressed: () => _deletePlaylist(playlist),
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
),
),
],
),
),
);
},
),
],
),
),
]),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_grid_tile.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_tile.dart';
import 'package:pinepods_mobile/ui/widgets/layout_selector.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
/// This class displays the list of podcasts the user is subscribed to on the PinePods server.
class PinepodsPodcasts extends StatefulWidget {
const PinepodsPodcasts({
super.key,
});
@override
State<PinepodsPodcasts> createState() => _PinepodsPodcastsState();
}
class _PinepodsPodcastsState extends State<PinepodsPodcasts> {
List<Podcast>? _podcasts;
List<Podcast>? _filteredPodcasts;
bool _isLoading = true;
String? _errorMessage;
final PinepodsService _pinepodsService = PinepodsService();
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadPodcasts();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterPodcasts();
});
}
void _filterPodcasts() {
if (_podcasts == null) {
_filteredPodcasts = null;
return;
}
if (_searchQuery.isEmpty) {
_filteredPodcasts = List.from(_podcasts!);
} else {
_filteredPodcasts = _podcasts!.where((podcast) {
return podcast.title.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
}
Future<void> _loadPodcasts() async {
final settingsBloc = Provider.of<SettingsBloc>(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 connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Initialize the service with the stored credentials
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final podcasts = await _pinepodsService.getUserPodcasts(settings.pinepodsUserId!);
setState(() {
_podcasts = podcasts;
_filterPodcasts(); // Initialize filtered list
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter podcasts...',
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,
),
),
),
const SizedBox(width: 12),
Material(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Theme.of(context).secondaryHeaderColor,
barrierLabel: L.of(context)!.scrim_layout_selector,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) => const LayoutSelectorWidget(),
);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.dashboard,
size: 20,
),
),
),
),
],
),
),
);
}
Widget _buildPodcastList(AppSettings settings) {
final podcasts = _filteredPodcasts ?? [];
if (podcasts.isEmpty && _searchQuery.isNotEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.search_off,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'No podcasts match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
var mode = settings.layout;
var size = mode == 1 ? 100.0 : 160.0;
if (mode == 0) {
// List view
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PinepodsPodcastTile(podcast: podcasts[index]);
},
childCount: podcasts.length,
addAutomaticKeepAlives: false,
),
);
}
// Grid view
return SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: size,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PinepodsPodcastGridTile(podcast: podcasts[index]);
},
childCount: podcasts.length,
),
);
}
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
if (_isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
}
if (_errorMessage != null) {
return SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: _loadPodcasts,
title: 'Podcasts Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load your podcasts',
);
}
if (_podcasts == null || _podcasts!.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.podcasts,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'You haven\'t subscribed to any podcasts yet. Search for podcasts to get started!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
builder: (context, settingsSnapshot) {
if (settingsSnapshot.hasData) {
return MultiSliver(
children: [
_buildSearchBar(),
_buildPodcastList(settingsSnapshot.data!),
],
);
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(
height: 0,
width: 0,
),
);
}
},
);
}
}

View File

@@ -0,0 +1,805 @@
// lib/ui/pinepods/queue.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/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/draggable_queue_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/position_utils.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
class PinepodsQueue extends StatefulWidget {
const PinepodsQueue({Key? key}) : super(key: key);
@override
State<PinepodsQueue> createState() => _PinepodsQueueState();
}
class _PinepodsQueueState extends State<PinepodsQueue> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
// Auto-scroll related variables
bool _isDragging = false;
bool _isAutoScrolling = false;
@override
void initState() {
super.initState();
_loadQueuedEpisodes();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _loadQueuedEpisodes() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(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.getQueuedEpisodes(userId);
// Enrich episodes with best available positions (local vs server)
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
context,
_pinepodsService,
episodes,
userId,
);
setState(() {
_episodes = enrichedEpisodes;
_isLoading = false;
});
// After loading episodes, check their local download status
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
} catch (e) {
setState(() {
_errorMessage = 'Failed to load queued episodes: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadQueuedEpisodes();
}
Future<void> _reorderEpisodes(int oldIndex, int newIndex) async {
// Adjust indices if moving down the list
if (newIndex > oldIndex) {
newIndex -= 1;
}
// Update local state immediately for smooth UI
setState(() {
final episode = _episodes.removeAt(oldIndex);
_episodes.insert(newIndex, episode);
});
// Get episode IDs in new order
final episodeIds = _episodes.map((e) => e.episodeId).toList();
// Call API to update order on server
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final success = await _pinepodsService.reorderQueue(userId, episodeIds);
if (!success) {
_showSnackBar('Failed to update queue order', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
}
} catch (e) {
_showSnackBar('Error updating queue order: $e', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
}
}
Future<void> _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<void> _showContextMenu(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
if (!mounted) return;
showDialog(
context: context,
barrierColor: Colors.black.withValues(alpha: 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<void> _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<void> _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<void> _saveEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_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<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_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<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_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<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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) {
// REMOVE the episode from the list since it's no longer queued
setState(() {
_episodes.removeAt(episodeIndex);
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
// This shouldn't happen since all episodes here are already queued
// But just in case, we'll handle it
success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
});
_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<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_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);
});
_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),
),
);
}
void _startAutoScroll(bool scrollUp) async {
if (_isAutoScrolling) return;
_isAutoScrolling = true;
while (_isDragging && _isAutoScrolling) {
// Find the nearest ScrollView controller
final ScrollController? scrollController = Scrollable.maybeOf(context)?.widget.controller;
if (scrollController != null && scrollController.hasClients) {
final currentOffset = scrollController.offset;
final maxScrollExtent = scrollController.position.maxScrollExtent;
if (scrollUp && currentOffset > 0) {
// Scroll up
final newOffset = (currentOffset - 8.0).clamp(0.0, maxScrollExtent);
scrollController.jumpTo(newOffset);
} else if (!scrollUp && currentOffset < maxScrollExtent) {
// Scroll down
final newOffset = (currentOffset + 8.0).clamp(0.0, maxScrollExtent);
scrollController.jumpTo(newOffset);
} else {
break; // Reached the edge
}
}
await Future.delayed(const Duration(milliseconds: 16));
}
_isAutoScrolling = false;
}
void _stopAutoScroll() {
_isAutoScrolling = false;
}
void _checkAutoScroll(double globalY) {
if (!_isDragging) return;
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double screenHeight = mediaQuery.size.height;
final double topPadding = mediaQuery.padding.top;
final double bottomPadding = mediaQuery.padding.bottom;
const double autoScrollThreshold = 80.0;
if (globalY < topPadding + autoScrollThreshold) {
// Near top, scroll up
if (!_isAutoScrolling) {
_startAutoScroll(true);
}
} else if (globalY > screenHeight - bottomPadding - autoScrollThreshold) {
// Near bottom, scroll down
if (!_isAutoScrolling) {
_startAutoScroll(false);
}
} else {
// In the middle, stop auto-scrolling
_stopAutoScroll();
}
}
@override
void dispose() {
_stopAutoScroll();
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading queue...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return SliverFillRemaining(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 48,
),
const SizedBox(height: 16),
Text(
_errorMessage,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _refresh,
child: const Text('Retry'),
),
],
),
),
),
);
}
if (_episodes.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue_music_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No queued episodes',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Episodes you queue will appear here',
style: TextStyle(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return _buildEpisodesList();
}
Widget _buildEpisodesList() {
return SliverMainAxisGroup(
slivers: [
// Header
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Queue',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Text(
'Drag to reorder',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
],
),
),
),
// Auto-scrolling reorderable episodes list wrapped with pointer detection
SliverToBoxAdapter(
child: Listener(
onPointerMove: (details) {
if (_isDragging) {
_checkAutoScroll(details.position.dy);
}
},
child: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderStart: (index) {
setState(() {
_isDragging = true;
});
},
onReorderEnd: (index) {
setState(() {
_isDragging = false;
});
_stopAutoScroll();
},
onReorder: _reorderEpisodes,
itemCount: _episodes.length,
itemBuilder: (context, index) {
final episode = _episodes[index];
return Container(
key: ValueKey(episode.episodeId),
margin: const EdgeInsets.only(bottom: 4),
child: DraggableQueueEpisodeCard(
episode: episode,
index: index,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(index),
onPlayPressed: () => _playEpisode(episode),
),
);
},
),
),
),
],
);
}
}

View File

@@ -0,0 +1,730 @@
// lib/ui/pinepods/saved.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 PinepodsSaved extends StatefulWidget {
const PinepodsSaved({Key? key}) : super(key: key);
@override
State<PinepodsSaved> createState() => _PinepodsSavedState();
}
class _PinepodsSavedState extends State<PinepodsSaved> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
List<PinepodsEpisode> _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();
_loadSavedEpisodes();
_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<void> _loadSavedEpisodes() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(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.getSavedEpisodes(userId);
// Enrich episodes with best available positions (local vs server)
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
context,
_pinepodsService,
episodes,
userId,
);
setState(() {
_episodes = enrichedEpisodes;
_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 saved episodes: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadSavedEpisodes();
}
Future<void> _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<void> _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<void> _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<void> _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<void> _saveEpisode(int episodeIndex) async {
// This shouldn't be called since all episodes here are already saved
// But just in case, we'll handle it
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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) {
// REMOVE the episode from the list since it's no longer saved
setState(() {
_episodes.removeAt(episodeIndex);
_filterEpisodes(); // Update filtered list after removal
});
_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<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_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<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_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<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_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);
});
_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<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(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);
});
_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);
});
_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 saved episodes...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return SliverServerErrorPage(
errorMessage: _errorMessage.isServerConnectionError
? null
: _errorMessage,
onRetry: _refresh,
title: 'Saved Episodes Unavailable',
subtitle: _errorMessage.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load saved episodes',
);
}
if (_episodes.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bookmark_outline,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No saved episodes',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Episodes you save 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
? 'Saved Episodes'
: '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: () => _showContextMenu(originalIndex),
onPlayPressed: () => _playEpisode(episode),
);
},
childCount: _filteredEpisodes.length + 1, // +1 for header
),
);
}
}

View File

@@ -0,0 +1,674 @@
// lib/ui/pinepods/search.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/search_history_service.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:provider/provider.dart';
class PinepodsSearch extends StatefulWidget {
final String? searchTerm;
const PinepodsSearch({
super.key,
this.searchTerm,
});
@override
State<PinepodsSearch> createState() => _PinepodsSearchState();
}
class _PinepodsSearchState extends State<PinepodsSearch> {
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
final PinepodsService _pinepodsService = PinepodsService();
final SearchHistoryService _searchHistoryService = SearchHistoryService();
SearchProvider _selectedProvider = SearchProvider.podcastIndex;
bool _isLoading = false;
bool _showHistory = false;
String? _errorMessage;
List<UnifiedPinepodsPodcast> _searchResults = [];
List<String> _searchHistory = [];
Set<String> _addedPodcastUrls = {};
@override
void initState() {
super.initState();
_searchFocusNode = FocusNode();
_searchController = TextEditingController();
if (widget.searchTerm != null) {
_searchController.text = widget.searchTerm!;
_performSearch(widget.searchTerm!);
} else {
_loadSearchHistory();
}
_initializeCredentials();
_searchController.addListener(_onSearchChanged);
}
void _initializeCredentials() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
}
}
@override
void dispose() {
_searchFocusNode.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _loadSearchHistory() async {
final history = await _searchHistoryService.getPodcastSearchHistory();
if (mounted) {
setState(() {
_searchHistory = history;
_showHistory = _searchController.text.isEmpty && history.isNotEmpty;
});
}
}
void _onSearchChanged() {
final query = _searchController.text.trim();
setState(() {
_showHistory = query.isEmpty && _searchHistory.isNotEmpty;
});
}
void _selectHistoryItem(String searchTerm) {
_searchController.text = searchTerm;
_performSearch(searchTerm);
}
Future<void> _removeHistoryItem(String searchTerm) async {
await _searchHistoryService.removePodcastSearchTerm(searchTerm);
await _loadSearchHistory();
}
Future<void> _performSearch(String query) async {
if (query.trim().isEmpty) {
setState(() {
_searchResults = [];
_errorMessage = null;
_showHistory = _searchHistory.isNotEmpty;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
_showHistory = false;
});
// Save search term to history
await _searchHistoryService.addPodcastSearchTerm(query);
await _loadSearchHistory();
try {
final result = await _pinepodsService.searchPodcasts(query, _selectedProvider);
final podcasts = result.getUnifiedPodcasts();
setState(() {
_searchResults = podcasts;
_isLoading = false;
});
// Check which podcasts are already added
await _checkAddedPodcasts();
} catch (e) {
setState(() {
_errorMessage = 'Search failed: $e';
_isLoading = false;
_searchResults = [];
});
}
}
Future<void> _checkAddedPodcasts() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) return;
for (final podcast in _searchResults) {
try {
final exists = await _pinepodsService.checkPodcastExists(
podcast.title,
podcast.url,
userId,
);
if (exists) {
setState(() {
_addedPodcastUrls.add(podcast.url);
});
}
} catch (e) {
// Ignore individual check failures
print('Failed to check podcast ${podcast.title}: $e');
}
}
}
Future<void> _togglePodcast(UnifiedPinepodsPodcast podcast) async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in to PinePods server', Colors.red);
return;
}
final isAdded = _addedPodcastUrls.contains(podcast.url);
try {
bool success;
if (isAdded) {
success = await _pinepodsService.removePodcast(
podcast.title,
podcast.url,
userId,
);
if (success) {
setState(() {
_addedPodcastUrls.remove(podcast.url);
});
_showSnackBar('Podcast removed', Colors.orange);
}
} else {
success = await _pinepodsService.addPodcast(podcast, userId);
if (success) {
setState(() {
_addedPodcastUrls.add(podcast.url);
});
_showSnackBar('Podcast added', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to ${isAdded ? 'remove' : 'add'} podcast', Colors.red);
}
} catch (e) {
_showSnackBar('Error: $e', Colors.red);
}
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Widget _buildSearchHistorySliver() {
return SliverFillRemaining(
hasScrollBody: false,
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Recent Podcast 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.clearPodcastSearchHistory();
await _loadSearchHistory();
},
child: Text(
'Clear All',
style: TextStyle(
color: Theme.of(context).hintColor,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 16),
if (_searchHistory.isEmpty)
Center(
child: Column(
children: [
const SizedBox(height: 50),
Icon(
Icons.search,
size: 64,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'Search for Podcasts',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Enter a search term above to find new podcasts to subscribe to',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
textAlign: TextAlign.center,
),
],
),
)
else
..._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(),
],
),
),
);
}
Widget _buildPodcastCard(UnifiedPinepodsPodcast podcast) {
final isAdded = _addedPodcastUrls.contains(podcast.url);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsPodcastDetails(
podcast: podcast,
isFollowing: isAdded,
onFollowChanged: (following) {
setState(() {
if (following) {
_addedPodcastUrls.add(podcast.url);
} else {
_addedPodcastUrls.remove(podcast.url);
}
});
},
),
),
);
},
child: Column(
children: [
// Podcast image and info
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Podcast artwork
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: podcast.artwork.isNotEmpty
? Image.network(
podcast.artwork,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 32,
),
);
},
)
: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 32,
),
),
),
const SizedBox(width: 12),
// Podcast info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
podcast.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
if (podcast.author.isNotEmpty)
Text(
'By ${podcast.author}',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).primaryColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
podcast.description,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.mic,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text(
'${podcast.episodeCount} episode${podcast.episodeCount != 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(width: 16),
if (podcast.explicit)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'E',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
// Follow/Unfollow button
IconButton(
onPressed: () => _togglePodcast(podcast),
icon: Icon(
isAdded ? Icons.remove_circle : Icons.add_circle,
color: isAdded ? Colors.red : Colors.green,
),
tooltip: isAdded ? 'Remove podcast' : 'Add podcast',
),
],
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
leading: IconButton(
tooltip: 'Back',
icon: Platform.isAndroid
? Icon(Icons.arrow_back, color: Theme.of(context).appBarTheme.foregroundColor)
: const Icon(Icons.arrow_back_ios),
onPressed: () => Navigator.pop(context),
),
title: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
autofocus: widget.searchTerm != null ? false : true,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
onTap: () {
setState(() {
_showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty;
});
},
decoration: const InputDecoration(
hintText: 'Search for podcasts',
border: InputBorder.none,
),
style: TextStyle(
color: Theme.of(context).primaryIconTheme.color,
fontSize: 18.0,
decorationColor: Theme.of(context).scaffoldBackgroundColor,
),
onSubmitted: _performSearch,
),
floating: false,
pinned: true,
snap: false,
actions: <Widget>[
IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchResults = [];
_errorMessage = null;
_showHistory = _searchHistory.isNotEmpty;
});
FocusScope.of(context).requestFocus(_searchFocusNode);
SystemChannels.textInput.invokeMethod<String>('TextInput.show');
},
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Container(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const Text(
'Search Provider: ',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Expanded(
child: DropdownButton<SearchProvider>(
value: _selectedProvider,
isExpanded: true,
items: SearchProvider.values.map((provider) {
return DropdownMenuItem(
value: provider,
child: Text(provider.name),
);
}).toList(),
onChanged: (provider) {
if (provider != null) {
setState(() {
_selectedProvider = provider;
});
// Re-search with new provider if there's a current search
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
}
},
),
),
],
),
),
),
),
// Search results or history
if (_showHistory)
_buildSearchHistorySliver()
else if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: PlatformProgressIndicator()),
)
else if (_errorMessage != null)
SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: () => _performSearch(_searchController.text),
title: 'Search Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to search for podcasts',
)
else if (_searchResults.isEmpty && _searchController.text.isNotEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Try searching with different keywords or switch search provider',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else if (_searchResults.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Search for podcasts',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Enter a search term to find podcasts',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return _buildPodcastCard(_searchResults[index]);
},
childCount: _searchResults.length,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,503 @@
// lib/ui/pinepods/user_stats.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/user_stats.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
class PinepodsUserStats extends StatefulWidget {
const PinepodsUserStats({super.key});
@override
State<PinepodsUserStats> createState() => _PinepodsUserStatsState();
}
class _PinepodsUserStatsState extends State<PinepodsUserStats> {
final PinepodsService _pinepodsService = PinepodsService();
UserStats? _userStats;
String? _pinepodsVersion;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initializeCredentials();
_loadUserStats();
}
void _initializeCredentials() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
}
}
/// Calculate responsive cross axis count for stats grid
int _getStatsCrossAxisCount(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
if (screenWidth > 800) return 3; // Wide tablets like iPad
if (screenWidth > 500) return 2; // Standard phones and small tablets
return 1; // Very small phones (< 500px)
}
/// Calculate responsive aspect ratio for stats cards
double _getStatsAspectRatio(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth <= 500) {
// Single column on small screens - generous height for content + proper padding
return 2.2; // Allows space for icon + title + value + padding, handles text wrapping
}
return 1.0; // Square aspect ratio for multi-column layouts
}
Future<void> _loadUserStats() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
setState(() {
_errorMessage = 'Not logged in';
_isLoading = false;
});
return;
}
try {
final futures = await Future.wait([
_pinepodsService.getUserStats(userId),
_pinepodsService.getPinepodsVersion(),
]);
setState(() {
_userStats = futures[0] as UserStats;
_pinepodsVersion = futures[1] as String;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Failed to load stats: $e';
_isLoading = false;
});
}
}
Future<void> _launchUrl(String url) async {
final logger = AppLogger();
logger.info('UserStats', 'Attempting to launch URL: $url');
try {
final uri = Uri.parse(url);
// Try to launch directly first (works better on Android)
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
logger.warning('UserStats', 'Direct URL launch failed, checking if URL can be launched');
// If direct launch fails, check if URL can be launched
final canLaunch = await canLaunchUrl(uri);
if (!canLaunch) {
throw Exception('No app available to handle this URL');
}
} else {
logger.info('UserStats', 'Successfully launched URL: $url');
}
} catch (e) {
logger.error('UserStats', 'Failed to launch URL: $url', e.toString());
// Show error if URL can't be launched
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open link: $url'),
backgroundColor: Colors.red,
),
);
}
}
}
Widget _buildStatCard(String label, String value, {IconData? icon, Color? iconColor}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(
icon,
size: 32,
color: iconColor ?? Theme.of(context).primaryColor,
),
const SizedBox(height: 8),
],
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Build sync status card that fits in the grid with consistent styling
Widget _buildSyncStatCard() {
if (_userStats == null) return const SizedBox.shrink();
final stats = _userStats!;
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
return _buildStatCard(
'Sync Status',
stats.syncStatusDescription,
icon: isNotSyncing ? Icons.sync_disabled : Icons.sync,
iconColor: isNotSyncing ? Colors.grey : null,
);
}
Widget _buildSyncStatusCard() {
if (_userStats == null) return const SizedBox.shrink();
final stats = _userStats!;
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(
isNotSyncing ? Icons.sync_disabled : Icons.sync,
size: 32,
color: isNotSyncing ? Colors.grey : Theme.of(context).primaryColor,
),
const SizedBox(height: 8),
Text(
'Podcast Sync Status',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
stats.syncStatusDescription,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (!isNotSyncing && stats.gpodderUrl.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
stats.gpodderUrl,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// PinePods Logo
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
image: const DecorationImage(
image: AssetImage('assets/images/pinepods-logo.png'),
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 16),
Text(
'App Version: v${Environment.projectVersion}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Server Version: ${_pinepodsVersion ?? "Unknown"}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Thanks for using PinePods! This app was born from a love for podcasts, of homelabs, and a desire to have a secure and central location to manage personal data.',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Copyright © 2025 Gooseberry Development',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The PinePods Mobile App is an open-source podcast player adapted from the Anytime Podcast Player (© 2020 Ben Hills). Portions of this application retain the original BSD 3-Clause license.',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
height: 1.3,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => _launchUrl('https://github.com/amugofjava/anytime_podcast_player'),
child: Text(
'View original project on GitHub',
style: TextStyle(
fontSize: 12,
decoration: TextDecoration.underline,
color: Theme.of(context).primaryColor,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
// Buttons
Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://pinepods.online'),
icon: const Icon(Icons.description),
label: const Text('PinePods Documentation'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://github.com/madeofpendletonwool/pinepods'),
icon: const Icon(Icons.code),
label: const Text('PinePods GitHub Repo'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://www.buymeacoffee.com/collinscoffee'),
icon: const Icon(Icons.coffee),
label: const Text('Buy me a Coffee'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
showLicensePage(context: context);
},
icon: const Icon(Icons.article_outlined),
label: const Text('Open Source Licenses'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('User Statistics'),
centerTitle: true,
),
body: _isLoading
? const Center(child: PlatformProgressIndicator())
: _errorMessage != null
? Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadUserStats();
},
child: const Text('Retry'),
),
],
),
),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// Statistics Grid
GridView.count(
crossAxisCount: _getStatsCrossAxisCount(context),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: _getStatsAspectRatio(context),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: [
_buildStatCard(
'User Created',
_userStats?.formattedUserCreated ?? '',
icon: Icons.calendar_today,
),
_buildStatCard(
'Podcasts Played',
_userStats?.podcastsPlayed.toString() ?? '',
icon: Icons.play_circle,
),
_buildStatCard(
'Time Listened',
_userStats?.formattedTimeListened ?? '',
icon: Icons.access_time,
),
_buildStatCard(
'Podcasts Added',
_userStats?.podcastsAdded.toString() ?? '',
icon: Icons.library_add,
),
_buildStatCard(
'Episodes Saved',
_userStats?.episodesSaved.toString() ?? '',
icon: Icons.bookmark,
),
_buildStatCard(
'Episodes Downloaded',
_userStats?.episodesDownloaded.toString() ?? '',
icon: Icons.download,
),
// Add sync status as a stat card to maintain consistent layout
_buildSyncStatCard(),
],
),
const SizedBox(height: 16),
// Info Card
_buildInfoCard(),
],
),
),
);
}
}