worldhopper/lib/widgets/recently_read_card.dart
Felipe M. 71651a6b21
All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
feat: koreader sync
2026-02-15 13:00:48 +01:00

462 lines
15 KiB
Dart

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/l10n/app_localizations.dart';
import 'package:worldhopper/config/constants.dart';
import 'package:worldhopper/helpers/snackbar_helper.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) {
final l10n = AppLocalizations.of(context);
// Watch developer mode so the provider stays alive and its async-loaded
// value is available when the context menu is opened.
final isDeveloperMode = ref.watch(developerModeNotifierProvider);
// 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(
l10n.recentlyReadPublicationNotFound,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
);
}
// Display with full metadata
return GestureDetector(
onTap: () => _handleTap(context, ref, publication),
onLongPressStart: (details) => _showContextMenu(
context, ref, details, publication, isDeveloperMode),
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)}% \u2022 ${_formatDate(context, progress.lastReadAt)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
],
),
),
],
),
),
);
},
loading: () => const Card(
child: Center(child: CircularProgressIndicator()),
),
error: (error, __) => Card(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
l10n.recentlyReadErrorLoading,
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.withValues(alpha: 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 {
final l10n = AppLocalizations.of(context);
// 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;
context.showInfoSnackBar(
l10n.recentlyReadOfflineEpubNotCached,
duration: const Duration(seconds: 3),
);
return; // Block navigation
} else if (entry.hasStreamLink) {
// Warn for OPDS-PS but allow navigation
if (!context.mounted) return;
context.showInfoSnackBar(
l10n.recentlyReadOfflinePagesWarning,
duration: const 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
context.showInfoSnackBar(
l10n.recentlyReadUnsupportedFormat,
duration: const Duration(seconds: 2),
);
}
}
Future<void> _showContextMenu(
BuildContext context,
WidgetRef ref,
LongPressStartDetails details,
dynamic publication,
bool isDeveloperMode,
) async {
final l10n = AppLocalizations.of(context);
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 items = <PopupMenuEntry<String>>[];
if (isDeveloperMode) {
items.add(
PopupMenuItem(
value: 'debug_raw_data',
child: Row(
children: [
const Icon(Icons.data_object, size: 20),
const SizedBox(width: 8),
Text(l10n.recentlyReadViewRawData),
],
),
),
);
}
items.add(
PopupMenuItem(
value: 'remove',
child: Row(
children: [
Icon(Icons.delete,
color: Theme.of(context).colorScheme.error, size: 20),
const SizedBox(width: 8),
Text(
l10n.recentlyReadRemove,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
),
);
final result = await showMenu<String>(
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<bool> _confirmRemoval(BuildContext context, String title) async {
final l10n = AppLocalizations.of(context);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.recentlyReadRemoveTitle),
content: Text(l10n.recentlyReadRemoveConfirmation(title)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(l10n.commonRemove),
),
],
),
);
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;
final l10n = AppLocalizations.of(context);
// Invalidate providers to refresh UI
ref.invalidate(recentlyReadProvider);
ref.invalidate(inProgressCountProvider);
ref.invalidate(completedCountProvider);
context.showInfoSnackBar(l10n.recentlyReadRemoved);
} catch (e) {
if (!context.mounted) return;
final l10n = AppLocalizations.of(context);
context.showErrorSnackBar(l10n.recentlyReadRemoveFailed(e.toString()));
}
}
void _showRawDataDialog(BuildContext context, dynamic publication) {
final l10n = AppLocalizations.of(context);
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: Text(l10n.recentlyReadRawData),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: SelectableText(
[
'// ReadingProgress',
progressJson,
'',
'// Publication',
publicationJson,
].join('\n'),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
),
),
);
}
String _formatDate(BuildContext context, DateTime date) {
final l10n = AppLocalizations.of(context);
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return l10n.timeToday;
} else if (difference.inDays == 1) {
return l10n.timeYesterday;
} else if (difference.inDays < 7) {
return l10n.timeDaysAgo(difference.inDays);
} else if (difference.inDays < 30) {
return l10n.timeWeeksAgo((difference.inDays / 7).floor());
} else if (difference.inDays < 365) {
return l10n.timeMonthsAgo((difference.inDays / 30).floor());
} else {
return l10n.timeYearsAgo((difference.inDays / 365).floor());
}
}
}