worldhopper/lib/widgets/recently_read_card.dart
Felipe M. 71e1be50ee
feat: add long-press to remove from recently read
Implemented RecentlyReadCard widget with long-press gesture detection
to remove publications from reading history. Features include:

- Long-press shows contextual menu with "Remove from Recently Read"
- Confirmation dialog prevents accidental deletion
- Automatic UI refresh after removal via Riverpod provider invalidation
- Success and error feedback with SnackBar notifications
- Proper async context checking to prevent UI errors

The card widget extracts all UI logic from LibraryScreen for better
code organization and reusability.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 20:34:17 +01:00

397 lines
13 KiB
Dart

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/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<bool>(
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<void> _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,
});
} else if (entry.hasStreamLink) {
// Navigate to image reader
context.push(AppConstants.routeReader, extra: {
'entry': entry,
'serverId': publication.serverId,
'initialPage': progress.currentPage,
});
} else {
// No valid link found
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Unsupported format'),
duration: Duration(seconds: 2),
),
);
}
}
Future<void> _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 result = await showMenu<String>(
context: context,
position: position,
items: [
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),
),
],
),
),
],
);
if (result == 'remove' && context.mounted) {
await _handleRemove(context, ref, publication.title);
}
}
Future<bool> _confirmRemoval(BuildContext context, String title) async {
final result = await showDialog<bool>(
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<void> _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,
),
);
}
}
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';
}
}
}