worldhopper/lib/widgets/publication_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

196 lines
6.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:worldhopper/l10n/app_localizations.dart';
import 'package:worldhopper/models/entry.dart';
import 'package:worldhopper/models/reading_progress.dart';
import 'package:worldhopper/providers/reading_progress_provider.dart';
/// Card widget displaying a publication or navigation entry
class PublicationCard extends ConsumerWidget {
final Entry entry;
final String serverId;
final VoidCallback onTap;
final Widget? badge;
const PublicationCard({
super.key,
required this.entry,
required this.serverId,
required this.onTap,
this.badge,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
// Watch local reading progress for this entry
final progressAsync = entry.isNavigation
? null
: ref.watch(readingProgressProvider(
ProgressKey(publicationId: entry.id, serverId: serverId),
));
// Compute progress value: local DB first, then server-side StreamLink
final double? progressValue = _resolveProgress(progressAsync);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Cover image with optional badge overlay
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
_buildCover(context),
if (badge != null) badge!,
],
),
),
// Progress bar (thin, below cover)
if (progressValue != null && progressValue > 0)
LinearProgressIndicator(
value: progressValue,
minHeight: 3,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
),
// Title and metadata
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (entry.authors.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
entry.authors.join(', '),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (entry.hasStreamLink) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.auto_stories,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
Text(
l10n.publicationPages(entry.streamLink!.pageCount),
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
],
if (entry.isNavigation) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.folder,
size: 14,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
l10n.publicationCollection,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
],
),
],
],
),
),
],
),
),
);
}
/// Resolve progress from local DB or server-side StreamLink.
double? _resolveProgress(AsyncValue<ReadingProgress?>? progressAsync) {
// Try local reading progress first
if (progressAsync != null) {
final localProgress = progressAsync.valueOrNull;
if (localProgress != null) {
return localProgress.progressPercentage;
}
}
// Fall back to server-side StreamLink.lastRead
if (entry.hasStreamLink) {
final streamLink = entry.streamLink!;
if (streamLink.lastRead != null &&
streamLink.lastRead! > 0 &&
streamLink.pageCount > 0) {
return (streamLink.lastRead! + 1) / streamLink.pageCount;
}
}
return null;
}
Widget _buildCover(BuildContext context) {
final imageUrl = entry.thumbnailUrl ?? entry.coverUrl;
if (imageUrl == null) {
return _buildPlaceholder(context);
}
return CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => _buildPlaceholder(context),
errorWidget: (context, url, error) => _buildPlaceholder(context),
);
}
Widget _buildPlaceholder(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
entry.isNavigation ? Icons.folder : Icons.book,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
),
);
}
}