import 'dart:convert'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/models/reading_progress.dart'; import 'package:worldhopper/providers/connectivity_provider.dart'; import 'package:worldhopper/providers/developer_mode_provider.dart'; import 'package:worldhopper/providers/publication_cache_provider.dart'; import 'package:worldhopper/providers/reading_progress_provider.dart'; import 'package:worldhopper/services/epub_download_service.dart'; /// Card widget displaying a recently read publication with long-press menu support class RecentlyReadCard extends ConsumerWidget { final ReadingProgress progress; const RecentlyReadCard({ super.key, required this.progress, }); @override Widget build(BuildContext context, WidgetRef ref) { // Load cached publication final publicationAsync = ref.watch( cachedPublicationProvider(progress.serverId, progress.publicationId), ); return publicationAsync.when( data: (publication) { if (publication == null) { // Show error state - this shouldn't happen in normal operation return Card( child: Center( child: Padding( padding: const EdgeInsets.all(16.0), child: Text( 'Publication not found', style: Theme.of(context).textTheme.bodySmall, ), ), ), ); } // Display with full metadata return GestureDetector( onTap: () => _handleTap(context, ref, publication), onLongPressStart: (details) => _showContextMenu(context, ref, details, publication), child: Card( clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Cover image with download indicator Expanded( child: Stack( fit: StackFit.expand, children: [ // Cover image publication.coverPath != null ? Image.file( File(publication.coverPath!), fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildPlaceholder(context), ) : publication.coverUrl != null ? CachedNetworkImage( imageUrl: publication.coverUrl!, fit: BoxFit.cover, placeholder: (_, __) => _buildPlaceholder(context), errorWidget: (_, __, ___) => _buildPlaceholder(context), ) : _buildPlaceholder(context), // Download indicator badge _buildDownloadIndicator(publication), ], ), ), // Progress bar LinearProgressIndicator( value: progress.progressPercentage, minHeight: 4, ), // Metadata Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Title Text( publication.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 4), // Authors if (publication.authors.isNotEmpty) Text( publication.authors.join(', '), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 4), // Progress and date Text( '${((progress.progressPercentage) * 100).toStringAsFixed(0)}% • ${_formatDate(progress.lastReadAt)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withOpacity(0.6), ), ), ], ), ), ], ), ), ); }, loading: () => const Card( child: Center(child: CircularProgressIndicator()), ), error: (error, __) => Card( child: Center( child: Padding( padding: const EdgeInsets.all(16.0), child: Text( 'Error loading publication', style: Theme.of(context).textTheme.bodySmall, ), ), ), ), ); } Widget _buildPlaceholder(BuildContext context) { return Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: const Icon(Icons.auto_stories, size: 64), ); } Widget _buildDownloadIndicator(dynamic publication) { // Only show indicator for EPUB publications final entry = publication.toOPDSEntry(); if (!entry.isEpub) { return const SizedBox.shrink(); } return Positioned( top: 8, right: 8, child: FutureBuilder( future: EpubDownloadService().isEpubCached(entry), builder: (context, snapshot) { // Only show badge if EPUB is cached if (snapshot.hasData && snapshot.data == true) { return Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.green, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: const Icon( Icons.cloud_download, size: 18, color: Colors.white, ), ); } return const SizedBox.shrink(); }, ), ); } Future _handleTap( BuildContext context, WidgetRef ref, dynamic publication, ) async { // Convert to OPDSEntry final entry = publication.toOPDSEntry(); // Check connectivity final connectivityState = ref.read(connectivityStateProvider); final isOnline = connectivityState.whenOrNull( data: (online) => online, ) ?? true; // If offline, check content availability if (!isOnline) { final checker = ref.read(offlineContentCheckerProvider); final isAvailable = await checker.isContentAvailableOffline(entry); if (!isAvailable) { if (entry.isEpub) { // Block EPUBs that aren't cached if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'This EPUB is not cached. Connect to internet to download.'), duration: Duration(seconds: 3), ), ); return; // Block navigation } else if (entry.hasStreamLink) { // Warn for OPDS-PS but allow navigation if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('You are offline. Pages may not load.'), duration: Duration(seconds: 3), ), ); // Continue to navigation } } } // Update last accessed ref .read(publicationCacheProvider.notifier) .touchPublication(publication.id); // Navigate to appropriate reader if (!context.mounted) return; if (entry.isEpub) { // Navigate to EPUB reader context.push(AppConstants.routeEpubReader, extra: { 'entry': entry, 'serverId': publication.serverId, 'feedUrl': progress.seriesFeedUrl, }); } else if (entry.hasStreamLink) { // Navigate to image reader context.push(AppConstants.routeReader, extra: { 'entry': entry, 'serverId': publication.serverId, 'initialPage': progress.currentPage, 'feedUrl': progress.seriesFeedUrl, }); } else { // No valid link found ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Unsupported format'), duration: Duration(seconds: 2), ), ); } } Future _showContextMenu( BuildContext context, WidgetRef ref, LongPressStartDetails details, dynamic publication, ) async { final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final RelativeRect position = RelativeRect.fromRect( Rect.fromPoints(details.globalPosition, details.globalPosition), Offset.zero & overlay.size, ); final isDeveloperMode = ref.read(developerModeNotifierProvider); final items = >[]; if (isDeveloperMode) { items.add( const PopupMenuItem( value: 'debug_raw_data', child: Row( children: [ Icon(Icons.data_object, size: 20), SizedBox(width: 8), Text('View raw data'), ], ), ), ); } items.add( PopupMenuItem( value: 'remove', child: Row( children: [ Icon(Icons.delete, color: Theme.of(context).colorScheme.error, size: 20), const SizedBox(width: 8), Text( 'Remove from Recently Read', style: TextStyle(color: Theme.of(context).colorScheme.error), ), ], ), ), ); final result = await showMenu( context: context, position: position, items: items, ); if (result == 'debug_raw_data' && context.mounted) { _showRawDataDialog(context, publication); } else if (result == 'remove' && context.mounted) { await _handleRemove(context, ref, publication.title); } } Future _confirmRemoval(BuildContext context, String title) async { final result = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Remove from Recently Read'), content: Text( 'Remove "$title" from your reading history?\n\n' 'This will not delete the book from your library.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, ), child: const Text('Remove'), ), ], ), ); return result ?? false; } Future _handleRemove( BuildContext context, WidgetRef ref, String title, ) async { final confirmed = await _confirmRemoval(context, title); if (!confirmed || !context.mounted) return; try { await ref .read(readingProgressNotifierProvider.notifier) .deleteProgress(progress.publicationId, progress.serverId); if (!context.mounted) return; // Invalidate providers to refresh UI ref.invalidate(recentlyReadProvider); ref.invalidate(inProgressCountProvider); ref.invalidate(completedCountProvider); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Removed from Recently Read')), ); } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to remove: $e'), backgroundColor: Theme.of(context).colorScheme.error, ), ); } } void _showRawDataDialog(BuildContext context, dynamic publication) { const encoder = JsonEncoder.withIndent(' '); final progressJson = encoder.convert(progress.toJson()); final publicationJson = encoder.convert(publication.toJson()); showDialog( context: context, builder: (context) => Dialog.fullscreen( child: Scaffold( appBar: AppBar( title: const Text('Raw Data'), leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: SelectableText( '// ReadingProgress\n$progressJson\n\n// Publication\n$publicationJson', style: const TextStyle( fontFamily: 'monospace', fontSize: 12, ), ), ), ), ), ); } String _formatDate(DateTime date) { final now = DateTime.now(); final difference = now.difference(date); if (difference.inDays == 0) { return 'Today'; } else if (difference.inDays == 1) { return 'Yesterday'; } else if (difference.inDays < 7) { return '${difference.inDays}d ago'; } else if (difference.inDays < 30) { return '${(difference.inDays / 7).floor()}w ago'; } else if (difference.inDays < 365) { return '${(difference.inDays / 30).floor()}mo ago'; } else { return '${(difference.inDays / 365).floor()}y ago'; } } }