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

193 lines
6.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:worldhopper/l10n/app_localizations.dart';
import 'package:worldhopper/models/server.dart';
/// Card widget displaying an OPDS server
class ServerCard extends StatelessWidget {
final Server server;
final VoidCallback onTap;
final VoidCallback onEdit;
final VoidCallback onDelete;
const ServerCard({
super.key,
required this.server,
required this.onTap,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Server icon
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.cloud,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 16),
// Server info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
server.name,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
server.url,
style:
Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Actions menu
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
onEdit();
} else if (value == 'delete') {
onDelete();
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(l10n.serverCardEdit),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 8),
Text(l10n.serverCardDelete,
style: const TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
// Server metadata
if (server.requiresAuth || server.lastSyncedAt != null) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
if (server.requiresAuth) ...[
Icon(
Icons.lock,
size: 16,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
const SizedBox(width: 4),
Text(
l10n.serverCardAuthenticated,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
],
if (server.requiresAuth && server.lastSyncedAt != null)
const SizedBox(width: 16),
if (server.lastSyncedAt != null) ...[
Icon(
Icons.sync,
size: 16,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
const SizedBox(width: 4),
Text(
l10n.serverCardLastSynced(
_formatDate(context, server.lastSyncedAt!)),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
],
],
),
],
],
),
),
),
);
}
String _formatDate(BuildContext context, DateTime date) {
final l10n = AppLocalizations.of(context);
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays > 7) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
} else if (difference.inDays > 0) {
return l10n.timeDaysAgo(difference.inDays);
} else if (difference.inHours > 0) {
return l10n.timeHoursAgo(difference.inHours);
} else if (difference.inMinutes > 0) {
return l10n.timeMinutesAgo(difference.inMinutes);
} else {
return l10n.timeJustNow;
}
}
}