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>
397 lines
13 KiB
Dart
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';
|
|
}
|
|
}
|
|
}
|