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
498 lines
16 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|