worldhopper/lib/widgets/recently_read_card.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

498 lines
16 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/entry.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/download_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);
// 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),
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) {
final entry = publication.toEntry();
return Positioned(
top: 8,
right: 8,
child: _DownloadIndicatorWidget(
serverId: publication.serverId,
entry: entry,
),
);
}
Future<void> _handleTap(
BuildContext context,
WidgetRef ref,
dynamic publication,
) async {
final l10n = AppLocalizations.of(context);
// Convert to Entry
final entry = publication.toEntry();
// Check connectivity
final connectivityState = ref.read(connectivityStateProvider);
final isOnline = connectivityState.whenOrNull(
data: (online) => online,
) ??
true;
// Check for downloaded content (local path)
final checker = ref.read(offlineContentCheckerProvider);
String? localPath;
localPath = await checker.getLocalPath(publication.serverId, entry.id);
// If offline, check content availability
if (!isOnline) {
final isAvailable = await checker.isContentAvailableOffline(
entry,
serverId: publication.serverId,
);
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,
if (localPath != null) 'localFilePath': localPath,
});
} else if (entry.hasStreamLink) {
// Navigate to image reader
context.push(AppConstants.routeReader, extra: {
'entry': entry,
'serverId': publication.serverId,
'initialPage': progress.currentPage,
'feedUrl': progress.seriesFeedUrl,
if (localPath != null) 'localContentPath': localPath,
});
} 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,
) 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 isDeveloperMode = ref.read(developerModeNotifierProvider);
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\n$progressJson\n\n// Publication\n$publicationJson',
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());
}
}
}
/// Widget that shows a download indicator badge checking both EPUB cache
/// and the downloaded chapters database
class _DownloadIndicatorWidget extends ConsumerWidget {
final String serverId;
final Entry entry;
const _DownloadIndicatorWidget({
required this.serverId,
required this.entry,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Check downloaded chapters
final downloadedAsync = ref.watch(chapterDownloadStatusProvider(
ChapterDownloadKey(serverId: serverId, entryId: entry.id),
));
return downloadedAsync.when(
data: (chapter) {
if (chapter != null && chapter.isComplete) {
return _buildBadge(Icons.download_done, Colors.green);
}
if (chapter != null && chapter.isDownloading) {
return _buildBadge(Icons.downloading, Colors.blue);
}
// Fall back to EPUB cache check
if (!entry.isEpub) return const SizedBox.shrink();
return FutureBuilder<bool>(
future: EpubDownloadService().isEpubCached(entry),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return _buildBadge(Icons.cloud_download, Colors.green);
}
return const SizedBox.shrink();
},
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
Widget _buildBadge(IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(icon, size: 18, color: Colors.white),
);
}
}