462 lines
15 KiB
Dart
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());
|
|
}
|
|
}
|
|
}
|