817 lines
25 KiB
Dart
817 lines
25 KiB
Dart
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();
|
|
}
|
|
} |