added cargo files
This commit is contained in:
817
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart
Normal file
817
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user